Repository: numba/numba

Issue:
Limit in Constant Dictionary Size with Python 3.10
<!--

Thanks for opening an issue! To help the Numba team handle your information
efficiently, please first ensure that there is no other issue present that
already describes the issue you have
(search at https://github.com/numba/numba/issues?&q=is%3Aissue).

-->

## Reporting a bug

<!--

Before submitting a bug report please ensure that you can check off these boxes:

-->

- [X] I have tried using the latest released version of Numba (most recent is
 visible in the change log (https://github.com/numba/numba/blob/main/CHANGE_LOG).
- [X] I have included a self contained code sample to reproduce the problem.
  i.e. it's possible to run as 'python bug.py'.

<!--

Please include details of the bug here, including, if applicable, what you
expected to happen!

-->

When using a large constant dictionary Python 3.10 the bytecode changes. Here is the reproducer:

```python3
import numba

@numba.njit
def f():
    d = {
        "a": 1,
        "b": 2,
        "c": 3,
        "d": 4,
        "e": 5,
        "f": 6,
        "g": 7,
        "h": 8,
        "i": 9,
        "j": 10,
        "k": 11,
        "l": 12,
        "m": 13,
        "n": 14,
        "o": 15,
        "p": 16,
        "q": 17,
        "r": 18,
        "s": 19,
    }
    return d

f()
```

In particular, when a dictionary gets too large it appears to construct smaller smaller dictionaries on the stack and then use `DICT_UPDATE` to combine the dictionaries. Numba doesn't seem to support the `update` method of dictionaries or `DICT_UPDATE`.

I think this should be treated as a regression as it works in earlier Python versions.



Discussion:
Summary of the discussion in developer meeting.

1. Update for non-constant dictionaries should depend use a typed Dict.
2. Providing update support between two typed Dicts should be just be a feature oversight and should be relatively simple to implement.
3. Providing update support for dictionaries with constant keys will be much harder because the constant dictionaries are meant to be constant at compile time. The best approach for handling constant dictionaries is probably to just modify the bytecode/IR to fuse these into a single constant dictionary.

Here is the example bytecode:

```python3
0	NOP(arg=None, lineno=5)
           2	BUILD_MAP(arg=0, lineno=5)
           4	LOAD_CONST(arg=1, lineno=6)
           6	LOAD_CONST(arg=2, lineno=6)
           8	MAP_ADD(arg=1, lineno=5)
          10	LOAD_CONST(arg=3, lineno=7)
          12	LOAD_CONST(arg=4, lineno=7)
          14	MAP_ADD(arg=1, lineno=5)
          16	LOAD_CONST(arg=5, lineno=8)
          18	LOAD_CONST(arg=6, lineno=8)
          20	MAP_ADD(arg=1, lineno=5)
          22	LOAD_CONST(arg=7, lineno=9)
          24	LOAD_CONST(arg=8, lineno=9)
          26	MAP_ADD(arg=1, lineno=5)
          28	LOAD_CONST(arg=9, lineno=10)
          30	LOAD_CONST(arg=10, lineno=10)
          32	MAP_ADD(arg=1, lineno=5)
          34	LOAD_CONST(arg=11, lineno=11)
          36	LOAD_CONST(arg=12, lineno=11)
          38	MAP_ADD(arg=1, lineno=5)
          40	LOAD_CONST(arg=13, lineno=12)
          42	LOAD_CONST(arg=14, lineno=12)
          44	MAP_ADD(arg=1, lineno=5)
          46	LOAD_CONST(arg=15, lineno=13)
          48	LOAD_CONST(arg=16, lineno=13)
          50	MAP_ADD(arg=1, lineno=5)
          52	LOAD_CONST(arg=17, lineno=14)
          54	LOAD_CONST(arg=18, lineno=14)
          56	MAP_ADD(arg=1, lineno=5)
          58	LOAD_CONST(arg=19, lineno=15)
          60	LOAD_CONST(arg=20, lineno=15)
          62	MAP_ADD(arg=1, lineno=5)
          64	LOAD_CONST(arg=21, lineno=16)
          66	LOAD_CONST(arg=22, lineno=16)
          68	MAP_ADD(arg=1, lineno=5)
          70	LOAD_CONST(arg=23, lineno=17)
          72	LOAD_CONST(arg=24, lineno=17)
          74	MAP_ADD(arg=1, lineno=5)
          76	LOAD_CONST(arg=25, lineno=18)
          78	LOAD_CONST(arg=26, lineno=18)
          80	MAP_ADD(arg=1, lineno=5)
          82	LOAD_CONST(arg=27, lineno=19)
          84	LOAD_CONST(arg=28, lineno=19)
          86	MAP_ADD(arg=1, lineno=5)
          88	LOAD_CONST(arg=29, lineno=20)
          90	LOAD_CONST(arg=30, lineno=20)
          92	MAP_ADD(arg=1, lineno=5)
          94	LOAD_CONST(arg=31, lineno=21)
          96	LOAD_CONST(arg=32, lineno=21)
          98	MAP_ADD(arg=1, lineno=5)
         100	LOAD_CONST(arg=33, lineno=22)
         102	LOAD_CONST(arg=34, lineno=22)
         104	MAP_ADD(arg=1, lineno=5)
         106	LOAD_CONST(arg=35, lineno=23)
         108	LOAD_CONST(arg=36, lineno=24)
         110	LOAD_CONST(arg=37, lineno=5)
         112	BUILD_CONST_KEY_MAP(arg=2, lineno=5)
         114	DICT_UPDATE(arg=1, lineno=5)
         116	STORE_FAST(arg=0, lineno=5)
         118	LOAD_FAST(arg=0, lineno=26)
         120	RETURN_VALUE(arg=None, lineno=26)
```
Seems like the interesting things start to happen around dictionaries of size 15, 16, 17, 18, and then larger dictionaries are a compound of these patterns.
```python
import dis
import string

def gen_func(sz):
    def key():
        for i in string.ascii_lowercase:
            for j in string.ascii_lowercase:
                yield f'{i}{j}'
    keygen = key()
    buf = []
    buf.append("def f():\n\td = {")
    for cp in range(sz):
        buf.append(f'\t\t"{next(keygen)}": {cp},')
    buf.append("\t}")
    func_txt = '\n'.join(buf)
    print(func_txt)
    l = {}
    exec(func_txt, {}, l)
    return l['f']

def dump(f):
    for inst in dis.get_instructions(f):
        print(inst.opname, inst.argval)

def analyse(dict_size):
    print(f"dict size: {dict_size}".center(80, '-'))
    func = gen_func(dict_size)
    dump(func)

for i in (15, 16, 17, 18, 64):
    analyse(i)
```
I believe this is the section of CPython responsible for that change. https://github.com/python/cpython/blob/v3.10.2/Python/compile.c#L4044. This seems consistent with the results of your experiments.

I quickly added this in to see what else is broken...
```patch
diff --git a/numba/core/byteflow.py b/numba/core/byteflow.py
index d32a5cf..894b9e7 100644
--- a/numba/core/byteflow.py
+++ b/numba/core/byteflow.py
@@ -1004,6 +1004,15 @@ class TraceRunner(object):
         state.append(inst, target=target, value=value, updatevar=updatevar,
                      res=res)
 
+    def op_DICT_UPDATE(self, state, inst):
+        value = state.pop()
+        index = inst.arg
+        target = state.peek(index)
+        updatevar = state.make_temp()
+        res = state.make_temp()
+        state.append(inst, target=target, value=value, updatevar=updatevar,
+                     res=res)
+
     def op_GET_ITER(self, state, inst):
         value = state.pop()
         res = state.make_temp()
diff --git a/numba/core/interpreter.py b/numba/core/interpreter.py
index bcce95c..c7eec83 100644
--- a/numba/core/interpreter.py
+++ b/numba/core/interpreter.py
@@ -1392,6 +1392,15 @@ class Interpreter(object):
                                   loc=self.loc)
         self.store(value=updateinst, name=res)
 
+    def op_DICT_UPDATE(self, inst, target, value, updatevar, res):
+        target = self.get(target)
+        value = self.get(value)
+        updateattr = ir.Expr.getattr(target, 'update', loc=self.loc)
+        self.store(value=updateattr, name=updatevar)
+        updateinst = ir.Expr.call(self.get(updatevar), (value,), (),
+                                  loc=self.loc)
+        self.store(value=updateinst, name=res)
+
     def op_BUILD_MAP(self, inst, items, size, res):
         got_items = [(self.get(k), self.get(v)) for k, v in items]
 
diff --git a/numba/typed/dictobject.py b/numba/typed/dictobject.py
index 74c6ab0..4287124 100644
--- a/numba/typed/dictobject.py
+++ b/numba/typed/dictobject.py
@@ -911,6 +911,17 @@ def impl_values(d):
 
     return impl
 
+@overload_method(types.DictType, 'update')
+def ol_dict_update(d, other):
+    if not isinstance(d, types.DictType):
+        return
+    if not isinstance(other, types.DictType):
+        return
+    def impl(d, other):
+        for k, v in other.items():
+            d[k] = v
+    return impl
+
```
and noticed that another issue is that whilst adding wiring for `dict.update` lets the code compile, the initial value information of a dictionary that is built using a `DICT_UPDATE` is lost. Example:
```python
import dis
import string
from numba import njit
import numpy as np

def gen_func(sz):
    def key():
        for i in string.ascii_lowercase:
            for j in string.ascii_lowercase:
                yield f'{i}{j}'
    keygen = key()
    buf = []
    buf.append("def f():\n\td = {")
    for cp in range(sz):
        buf.append(f'\t\t"{next(keygen)}": {cp},')
    buf.append("\t}; return d")
    func_txt = '\n'.join(buf)
    print(func_txt)
    l = {}
    exec(func_txt, {}, l)
    return l['f']

def dump(f):
    for inst in dis.get_instructions(f):
        print(inst.opname, inst.argval)

def analyse(dict_size):
    print(f"dict size: {dict_size}".center(80, '-'))
    func = gen_func(dict_size)
    dump(func)
    return func

print('14 keys'.center(80, '-'))
tmp14 = njit(analyse(14))
tmp14()
tmp14.inspect_types()

print('18 keys'.center(80, '-'))
tmp18 = njit(analyse(18))
tmp18()
tmp18.inspect_types()
```
Note how the 14 keys version has:
```
  $38return_value.31 = cast(value=d)  :: DictType[unicode_type,int64]<iv={'aa': 0, 'ab': 1, 'ac': 2, 'ad': 3, 'ae': 4, 'af': 5, 'ag': 6, 'ah': 7, 'ai': 8, 'aj': 9, 'ak': 10, 'al': 11, 'am': 12, 'an': 13}>
```
whereas the 18 keys version has:
```
$118return_value.75 = cast(value=d)  :: DictType[unicode_type,int64]<iv=None>
```


Resolving patch:
diff --git a/numba/core/byteflow.py b/numba/core/byteflow.py
--- a/numba/core/byteflow.py
+++ b/numba/core/byteflow.py
@@ -1008,6 +1008,15 @@ def op_SET_UPDATE(self, state, inst):
         state.append(inst, target=target, value=value, updatevar=updatevar,
                      res=res)
 
+    def op_DICT_UPDATE(self, state, inst):
+        value = state.pop()
+        index = inst.arg
+        target = state.peek(index)
+        updatevar = state.make_temp()
+        res = state.make_temp()
+        state.append(inst, target=target, value=value, updatevar=updatevar,
+                     res=res)
+
     def op_GET_ITER(self, state, inst):
         value = state.pop()
         res = state.make_temp()
diff --git a/numba/core/interpreter.py b/numba/core/interpreter.py
--- a/numba/core/interpreter.py
+++ b/numba/core/interpreter.py
@@ -7,6 +7,7 @@
 
 from numba.core import errors, dataflow, controlflow, ir, config
 from numba.core.errors import NotDefinedError, UnsupportedError, error_extras
+from numba.core.ir_utils import get_definition, guard
 from numba.core.utils import (PYVERSION, BINOPS_TO_OPERATORS,
                               INPLACE_BINOPS_TO_OPERATORS,)
 from numba.core.byteflow import Flow, AdaptDFA, AdaptCFA
@@ -80,26 +81,38 @@ def get_assignment_source(self, destname):
         return None
 
 
-def _remove_assignment_definition(old_body, idx, func_ir):
+def _remove_assignment_definition(old_body, idx, func_ir, already_deleted_defs):
     """
     Deletes the definition defined for old_body at index idx
     from func_ir. We assume this stmt will be deleted from
     new_body.
+
+    In some optimizations we may update the same variable multiple times.
+    In this situation, we only need to delete a particular definition once,
+    this is tracked in already_deleted_def, which is a map from
+    assignment name to the set of values that have already been
+    deleted.
     """
     lhs = old_body[idx].target.name
     rhs = old_body[idx].value
     if rhs in func_ir._definitions[lhs]:
         func_ir._definitions[lhs].remove(rhs)
-    else:
+        already_deleted_defs[lhs].add(rhs)
+    elif rhs not in already_deleted_defs[lhs]:
         raise UnsupportedError(
             "Inconsistency found in the definitions while executing"
-            " peep_hole_call_function_ex_to_call_function_kw. This suggests"
-            " an internal error or inconsistency elsewhere in the compiler."
+            " a peephole optimization. This suggests an internal"
+            " error or inconsistency elsewhere in the compiler."
         )
 
 
 def _call_function_ex_replace_kws_small(
-    old_body, keyword_expr, new_body, buildmap_idx, func_ir
+    old_body,
+    keyword_expr,
+    new_body,
+    buildmap_idx,
+    func_ir,
+    already_deleted_defs
 ):
     """
     Extracts the kws args passed as varkwarg
@@ -128,12 +141,21 @@ def _call_function_ex_replace_kws_small(
     # index to None. Nones will be removed later.
     new_body[buildmap_idx] = None
     # Remove the definition.
-    _remove_assignment_definition(old_body, buildmap_idx, func_ir)
+    _remove_assignment_definition(
+        old_body, buildmap_idx, func_ir, already_deleted_defs
+    )
     return kws
 
 
 def _call_function_ex_replace_kws_large(
-    old_body, buildmap_name, buildmap_idx, search_end, new_body, func_ir, errmsg
+    old_body,
+    buildmap_name,
+    buildmap_idx,
+    search_end,
+    new_body,
+    func_ir,
+    errmsg,
+    already_deleted_defs
 ):
     """
     Extracts the kws args passed as varkwarg
@@ -166,7 +188,9 @@ def _call_function_ex_replace_kws_large(
     # Remove the build_map from the body.
     new_body[buildmap_idx] = None
     # Remove the definition.
-    _remove_assignment_definition(old_body, buildmap_idx, func_ir)
+    _remove_assignment_definition(
+        old_body, buildmap_idx, func_ir, already_deleted_defs
+    )
     kws = []
     search_start = buildmap_idx + 1
     while search_start <= search_end:
@@ -252,14 +276,23 @@ def _call_function_ex_replace_kws_large(
         new_body[search_start] = None
         new_body[search_start + 1] = None
         # Remove the definitions.
-        _remove_assignment_definition(old_body, search_start, func_ir)
-        _remove_assignment_definition(old_body, search_start + 1, func_ir)
+        _remove_assignment_definition(
+            old_body, search_start, func_ir, already_deleted_defs
+        )
+        _remove_assignment_definition(
+            old_body, search_start + 1, func_ir, already_deleted_defs
+        )
         search_start += 2
     return kws
 
 
 def _call_function_ex_replace_args_small(
-    old_body, tuple_expr, new_body, buildtuple_idx, func_ir
+    old_body,
+    tuple_expr,
+    new_body,
+    buildtuple_idx,
+    func_ir,
+    already_deleted_defs
 ):
     """
     Extracts the args passed as vararg
@@ -281,13 +314,21 @@ def _call_function_ex_replace_args_small(
     # Delete the build tuple
     new_body[buildtuple_idx] = None
     # Remove the definition.
-    _remove_assignment_definition(old_body, buildtuple_idx, func_ir)
+    _remove_assignment_definition(
+        old_body, buildtuple_idx, func_ir, already_deleted_defs
+    )
     # Return the args.
     return tuple_expr.items
 
 
 def _call_function_ex_replace_args_large(
-    old_body, vararg_stmt, new_body, search_end, func_ir, errmsg
+    old_body,
+    vararg_stmt,
+    new_body,
+    search_end,
+    func_ir,
+    errmsg,
+    already_deleted_defs
 ):
     """
     Extracts the args passed as vararg
@@ -328,7 +369,9 @@ def _call_function_ex_replace_args_large(
         # If there is an initial assignment, delete it
         new_body[search_end] = None
         # Remove the definition.
-        _remove_assignment_definition(old_body, search_end, func_ir)
+        _remove_assignment_definition(
+            old_body, search_end, func_ir, already_deleted_defs
+        )
         search_end -= 1
     else:
         # There must always be an initial assignement
@@ -348,7 +391,9 @@ def _call_function_ex_replace_args_large(
         ):
             new_body[search_end] = None
             # Remove the definition.
-            _remove_assignment_definition(old_body, search_end, func_ir)
+            _remove_assignment_definition(
+                old_body, search_end, func_ir, already_deleted_defs
+            )
             # If we have reached the build_tuple we exit.
             break
         else:
@@ -400,8 +445,12 @@ def _call_function_ex_replace_args_large(
             new_body[search_end] = None
             new_body[search_end - 1] = None
             # Remove the definitions.
-            _remove_assignment_definition(old_body, search_end, func_ir)
-            _remove_assignment_definition(old_body, search_end - 1, func_ir)
+            _remove_assignment_definition(
+                old_body, search_end, func_ir, already_deleted_defs
+            )
+            _remove_assignment_definition(
+                old_body, search_end - 1, func_ir, already_deleted_defs
+            )
             search_end -= 2
             # Avoid any space between appends
             keep_looking = True
@@ -477,6 +526,9 @@ def peep_hole_call_function_ex_to_call_function_kw(func_ir):
 
             a_val = 1 if flag else 0
             f(a=a_val, ...)""")
+
+    # Track which definitions have already been deleted
+    already_deleted_defs = collections.defaultdict(set)
     for blk in func_ir.blocks.values():
         blk_changed = False
         new_body = []
@@ -546,6 +598,7 @@ def peep_hole_call_function_ex_to_call_function_kw(func_ir):
                         new_body,
                         varkwarg_loc,
                         func_ir,
+                        already_deleted_defs,
                     )
                 else:
                     # n_kws > 15 case.
@@ -568,6 +621,7 @@ def peep_hole_call_function_ex_to_call_function_kw(func_ir):
                         new_body,
                         func_ir,
                         errmsg,
+                        already_deleted_defs,
                     )
                 start_search = varkwarg_loc
                 # Vararg isn't required to be provided.
@@ -611,6 +665,7 @@ def peep_hole_call_function_ex_to_call_function_kw(func_ir):
                             new_body,
                             vararg_loc,
                             func_ir,
+                            already_deleted_defs,
                         )
                     elif (
                         isinstance(args_def.value, ir.Expr)
@@ -650,13 +705,16 @@ def peep_hole_call_function_ex_to_call_function_kw(func_ir):
                             vararg_loc,
                             func_ir,
                             errmsg,
+                            already_deleted_defs,
                         )
                 # Create a new call updating the args and kws
                 new_call = ir.Expr.call(
                     call.func, args, kws, call.loc, target=call.target
                 )
                 # Drop the existing definition for this stmt.
-                _remove_assignment_definition(blk.body, i, func_ir)
+                _remove_assignment_definition(
+                    blk.body, i, func_ir, already_deleted_defs
+                )
                 # Update the statement
                 stmt = ir.Assign(new_call, stmt.target, stmt.loc)
                 # Update the definition
@@ -685,7 +743,8 @@ def peep_hole_call_function_ex_to_call_function_kw(func_ir):
             new_body.append(stmt)
         # Replace the block body if we changed the IR
         if blk_changed:
-            blk.body = [x for x in new_body if x is not None]
+            blk.body.clear()
+            blk.body.extend([x for x in new_body if x is not None])
     return func_ir
 
 
@@ -943,6 +1002,281 @@ def peep_hole_delete_with_exit(func_ir):
     return func_ir
 
 
+def peep_hole_fuse_dict_add_updates(func_ir):
+    """
+    This rewrite removes d1._update_from_bytecode(d2)
+    calls that are between two dictionaries, d1 and d2,
+    in the same basic block. This pattern can appear as a
+    result of Python 3.10 bytecode emission changes, which
+    prevent large constant literal dictionaries
+    (> 15 elements) from being constant. If both dictionaries
+    are constant dictionaries defined in the same block and
+    neither is used between the update call, then we replace d1
+    with a new definition that combines the two dictionaries. At
+    the bytecode translation stage we convert DICT_UPDATE into
+    _update_from_bytecode, so we know that _update_from_bytecode
+    always comes from the bytecode change and not user code.
+
+    Python 3.10 may also rewrite the individual dictionaries
+    as an empty build_map + many map_add. Here we again look
+    for an _update_from_bytecode, and if so we replace these
+    with a single constant dictionary.
+
+    When running this algorithm we can always safely remove d2.
+
+    This is the relevant section of the CPython 3.10 that causes
+    this bytecode change:
+    https://github.com/python/cpython/blob/3.10/Python/compile.c#L4048
+    """
+
+    # This algorithm fuses build_map expressions into the largest
+    # possible build map before use. For example, if we have an
+    # IR that looks like this:
+    #
+    #   $d1 = build_map([])
+    #   $key = const("a")
+    #   $value = const(2)
+    #   $setitem_func = getattr($d1, "__setitem__")
+    #   $unused1 = call (setitem_func, ($key, $value))
+    #   $key2 = const("b")
+    #   $value2 = const(3)
+    #   $d2 = build_map([($key2, $value2)])
+    #   $update_func = getattr($d1, "_update_from_bytecode")
+    #   $unused2 = call ($update_func, ($d2,))
+    #   $othervar = None
+    #   $retvar = cast($othervar)
+    #   return $retvar
+    #
+    # Then the IR is rewritten such that any __setitem__ and
+    # _update_from_bytecode operations are fused into the original buildmap.
+    # The new buildmap is then added to the
+    # last location where it had previously had encountered a __setitem__,
+    # _update_from_bytecode, or build_map before any other uses.
+    # The new IR would look like:
+    #
+    #   $key = const("a")
+    #   $value = const(2)
+    #   $key2 = const("b")
+    #   $value2 = const(3)
+    #   $d1 = build_map([($key, $value), ($key2, $value2)])
+    #   $othervar = None
+    #   $retvar = cast($othervar)
+    #   return $retvar
+    #
+    # Note that we don't push $d1 to the bottom of the block. This is because
+    # some values may be found below this block (e.g pop_block) that are pattern
+    # matched in other locations, such as objmode handling. It should be safe to
+    # move a map to the last location at which there was _update_from_bytecode.
+
+    errmsg = textwrap.dedent("""
+        A DICT_UPDATE op-code was encountered that could not be replaced.
+        If you have created a large constant dictionary, this may
+        be an an indication that you are using inlined control
+        flow. You can resolve this issue by moving the control flow out of
+        the dicitonary constructor. For example, if you have
+
+            d = {a: 1 if flag else 0, ...)
+
+        Replace that with:
+
+            a_val = 1 if flag else 0
+            d = {a: a_val, ...)""")
+
+    already_deleted_defs = collections.defaultdict(set)
+    for blk in func_ir.blocks.values():
+        new_body = []
+        # literal map var name -> block idx of the original build_map
+        lit_map_def_idx = {}
+        # literal map var name -> list(map_uses)
+        # This is the index of every build_map or __setitem__
+        # in the IR that will need to be removed if the map
+        # is updated.
+        lit_map_use_idx = collections.defaultdict(list)
+        # literal map var name -> list of key/value items for build map
+        map_updates = {}
+        blk_changed = False
+
+        for i, stmt in enumerate(blk.body):
+            # What instruction should we append
+            new_inst = stmt
+            # Name that should be skipped when tracking used
+            # vars in statement. This is always the lhs with
+            # a build_map.
+            stmt_build_map_out = None
+            if isinstance(stmt, ir.Assign) and isinstance(stmt.value, ir.Expr):
+                if stmt.value.op == "build_map":
+                    # Skip the output build_map when looking for used vars.
+                    stmt_build_map_out = stmt.target.name
+                    # If we encounter a build map add it to the
+                    # tracked maps.
+                    lit_map_def_idx[stmt.target.name] = i
+                    lit_map_use_idx[stmt.target.name].append(i)
+                    map_updates[stmt.target.name] = stmt.value.items.copy()
+                elif stmt.value.op == "call" and i > 0:
+                    # If we encounter a call we may need to replace
+                    # the body
+                    func_name = stmt.value.func.name
+                    # If we have an update or a setitem
+                    # it will be the previous expression.
+                    getattr_stmt = blk.body[i - 1]
+                    args = stmt.value.args
+                    if (
+                        isinstance(getattr_stmt, ir.Assign)
+                        and getattr_stmt.target.name == func_name
+                        and isinstance(getattr_stmt.value, ir.Expr)
+                        and getattr_stmt.value.op == "getattr"
+                        and getattr_stmt.value.attr in (
+                            "__setitem__", "_update_from_bytecode"
+                        )
+                    ):
+                        update_map_name = getattr_stmt.value.value.name
+                        attr = getattr_stmt.value.attr
+                        if (attr == "__setitem__"
+                           and update_map_name in lit_map_use_idx):
+                            # If we have a setitem, update the lists
+                            map_updates[update_map_name].append(args)
+                            # Update the list of instructions that would
+                            # need to be removed to include the setitem
+                            # and the the getattr
+                            lit_map_use_idx[update_map_name].extend([i - 1, i])
+                        elif attr == "_update_from_bytecode":
+                            d2_map_name = args[0].name
+                            if (update_map_name in lit_map_use_idx
+                               and d2_map_name in lit_map_use_idx):
+                                # If we have an update and the arg is also
+                                # a literal dictionary, fuse the lists.
+                                map_updates[update_map_name].extend(
+                                    map_updates[d2_map_name]
+                                )
+                                # Delete the old IR for d1 and d2
+                                lit_map_use_idx[update_map_name].extend(
+                                    lit_map_use_idx[d2_map_name]
+                                )
+                                lit_map_use_idx[update_map_name].append(i - 1)
+                                for linenum in lit_map_use_idx[update_map_name]:
+                                    # Drop the existing definition.
+                                    _remove_assignment_definition(
+                                        blk.body,
+                                        linenum,
+                                        func_ir,
+                                        already_deleted_defs,
+                                    )
+                                    # Delete it from the new block
+                                    new_body[linenum] = None
+                                # Delete the maps from dicts
+                                del lit_map_def_idx[d2_map_name]
+                                del lit_map_use_idx[d2_map_name]
+                                del map_updates[d2_map_name]
+                                # Add d1 as the new instruction, removing the
+                                # old definition.
+                                _remove_assignment_definition(
+                                    blk.body, i, func_ir, already_deleted_defs
+                                )
+                                new_inst = _build_new_build_map(
+                                    func_ir,
+                                    update_map_name,
+                                    blk.body,
+                                    lit_map_def_idx[update_map_name],
+                                    map_updates[update_map_name],
+                                )
+                                # Update d1 in lit_map_use_idx to just the new
+                                # definition and clear the previous list.
+                                lit_map_use_idx[update_map_name].clear()
+                                lit_map_use_idx[update_map_name].append(i)
+                                # Mark that this block has been modified
+                                blk_changed = True
+                            else:
+                                # If we cannot remove _update_from_bytecode
+                                # Then raise an error for the user.
+                                raise UnsupportedError(errmsg)
+
+            # Check if we need to drop any maps from being tracked.
+            # Skip the setitem/_update_from_bytecode getattr that
+            # will be removed when handling their call in the next
+            # iteration.
+            if not (
+                isinstance(stmt, ir.Assign)
+                and isinstance(stmt.value, ir.Expr)
+                and stmt.value.op == "getattr"
+                and stmt.value.value.name in lit_map_use_idx
+                and stmt.value.attr in ("__setitem__", "_update_from_bytecode")
+            ):
+                for var in stmt.list_vars():
+                    # If a map is used it cannot be fused later in
+                    # the block. As a result we delete it from
+                    # the dicitonaries
+                    if (
+                        var.name in lit_map_use_idx
+                        and var.name != stmt_build_map_out
+                    ):
+                        del lit_map_def_idx[var.name]
+                        del lit_map_use_idx[var.name]
+                        del map_updates[var.name]
+
+            # Append the instruction to the new block
+            new_body.append(new_inst)
+
+        if blk_changed:
+            # If the block is changed replace the block body.
+            blk.body.clear()
+            blk.body.extend([x for x in new_body if x is not None])
+
+    return func_ir
+
+
+def _build_new_build_map(func_ir, name, old_body, old_lineno, new_items):
+    """
+    Create a new build_map with a new set of key/value items
+    but all the other info the same.
+    """
+    old_assign = old_body[old_lineno]
+    old_target = old_assign.target
+    old_bm = old_assign.value
+    # Build the literals
+    literal_keys = []
+    # Track the constant key/values to set the literal_value
+    # field of build_map properly
+    values = []
+    for pair in new_items:
+        k, v = pair
+        key_def = guard(get_definition, func_ir, k)
+        if isinstance(key_def, (ir.Const, ir.Global, ir.FreeVar)):
+            literal_keys.append(key_def.value)
+        value_def = guard(get_definition, func_ir, v)
+        if isinstance(value_def, (ir.Const, ir.Global, ir.FreeVar)):
+            values.append(value_def.value)
+        else:
+            # Append unknown value if not a literal.
+            values.append(_UNKNOWN_VALUE(v.name))
+
+    value_indexes = {}
+    if len(literal_keys) == len(new_items):
+        # All keys must be literals to have any literal values.
+        literal_value = {x: y for x, y in zip(literal_keys, values)}
+        for i, k in enumerate(literal_keys):
+            value_indexes[k] = i
+    else:
+        literal_value = None
+
+    # Construct a new build map.
+    new_bm = ir.Expr.build_map(
+        items=new_items,
+        size=len(new_items),
+        literal_value=literal_value,
+        value_indexes=value_indexes,
+        loc=old_bm.loc,
+    )
+
+    # The previous definition has already been removed
+    # when updating the IR in peep_hole_fuse_dict_add_updates
+    func_ir._definitions[name].append(new_bm)
+
+    # Return a new assign.
+    return ir.Assign(
+        new_bm, ir.Var(old_target.scope, name, old_target.loc), new_bm.loc
+    )
+
+
 class Interpreter(object):
     """A bytecode interpreter that builds up the IR.
     """
@@ -1019,6 +1353,7 @@ def interpret(self, bytecode):
             # any large number of arguments from a list to a
             # tuple.
             peepholes.append(peep_hole_call_function_ex_to_call_function_kw)
+            peepholes.append(peep_hole_fuse_dict_add_updates)
 
         post_processed_ir = self.post_process(peepholes, func_ir)
         return post_processed_ir
@@ -2048,6 +2383,22 @@ def op_SET_UPDATE(self, inst, target, value, updatevar, res):
                                   loc=self.loc)
         self.store(value=updateinst, name=res)
 
+    def op_DICT_UPDATE(self, inst, target, value, updatevar, res):
+        target = self.get(target)
+        value = self.get(value)
+        # We generate _update_from_bytecode instead of update so we can
+        # differentiate between user .update() calls and those from the
+        # bytecode. This is then used to recombine dictionaries in peephole
+        # optimizations. See the dicussion in this PR about why:
+        # https://github.com/numba/numba/pull/7964/files#r868229306
+        updateattr = ir.Expr.getattr(
+            target, '_update_from_bytecode', loc=self.loc
+        )
+        self.store(value=updateattr, name=updatevar)
+        updateinst = ir.Expr.call(self.get(updatevar), (value,), (),
+                                  loc=self.loc)
+        self.store(value=updateinst, name=res)
+
     def op_BUILD_MAP(self, inst, items, size, res):
         got_items = [(self.get(k), self.get(v)) for k, v in items]
 
diff --git a/numba/typed/dictobject.py b/numba/typed/dictobject.py
--- a/numba/typed/dictobject.py
+++ b/numba/typed/dictobject.py
@@ -912,6 +912,19 @@ def impl(d):
     return impl
 
 
+@overload_method(types.DictType, 'update')
+def ol_dict_update(d, other):
+    if not isinstance(d, types.DictType):
+        return
+    if not isinstance(other, types.DictType):
+        return
+
+    def impl(d, other):
+        for k, v in other.items():
+            d[k] = v
+    return impl
+
+
 @overload(operator.eq)
 def impl_equal(da, db):
     if not isinstance(da, types.DictType):
@@ -1299,6 +1312,7 @@ def literalstrkeydict_banned_impl_delitem(d, k):
 @overload_method(types.LiteralStrKeyDict, 'pop')
 @overload_method(types.LiteralStrKeyDict, 'clear')
 @overload_method(types.LiteralStrKeyDict, 'setdefault')
+@overload_method(types.LiteralStrKeyDict, 'update')
 def literalstrkeydict_banned_impl_mutators(d, *args):
     if not isinstance(d, types.LiteralStrKeyDict):
         return


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
@@ -1007,6 +1007,47 @@ def foo():
 
         self.assertTrue(foo())
 
+    def test_dict_update(self):
+        """
+        Tests dict.update works with various dictionaries.
+        """
+        n = 10
+
+        def f1(n):
+            """
+            Test update with a regular dictionary.
+            """
+            d1 = {i: i + 1 for i in range(n)}
+            d2 = {3 * i: i for i in range(n)}
+            d1.update(d2)
+            return d1
+
+        py_func = f1
+        cfunc = njit()(f1)
+        a = py_func(n)
+        b = cfunc(n)
+        self.assertEqual(a, b)
+
+        def f2(n):
+            """
+            Test update where one of the dictionaries
+            is created as a Python literal.
+            """
+            d1 = {
+                1: 2,
+                3: 4,
+                5: 6
+            }
+            d2 = {3 * i: i for i in range(n)}
+            d1.update(d2)
+            return d1
+
+        py_func = f2
+        cfunc = njit()(f2)
+        a = py_func(n)
+        b = cfunc(n)
+        self.assertEqual(a, b)
+
 
 class TestDictTypeCasting(TestCase):
     def check_good(self, fromty, toty):
@@ -2361,6 +2402,28 @@ def bar():
 
         self.assertPreciseEqual(bar(), bar.py_func())
 
+    def test_update_error(self):
+        # Tests that dict.update produces a reasonable
+        # error with a LiteralStrKeyDict input.
+        @njit
+        def foo():
+
+            d1 = {
+                'a': 2,
+                'b': 4,
+                'c': 'a'
+            }
+            d1.update({'x': 3})
+            return d1
+
+        with self.assertRaises(TypingError) as raises:
+            foo()
+
+        self.assertIn(
+            "Cannot mutate a literal dictionary",
+            str(raises.exception)
+        )
+
 
 if __name__ == '__main__':
     unittest.main()
diff --git a/numba/tests/test_interpreter.py b/numba/tests/test_interpreter.py
--- a/numba/tests/test_interpreter.py
+++ b/numba/tests/test_interpreter.py
@@ -2,8 +2,9 @@
 Test bytecode fixes provided in interpreter.py
 """
 import unittest
-
-from numba import jit, njit, objmode
+from numba import jit, njit, objmode, typeof, literally
+from numba.extending import overload
+from numba.core import types
 from numba.core.errors import UnsupportedError
 from numba.tests.support import TestCase, MemoryLeakMixin, skip_unless_py10
 
@@ -622,6 +623,504 @@ def inline_func(flag):
         self.assertEqual(a, b)
 
 
+class TestLargeConstDict(TestCase, MemoryLeakMixin):
+    """
+    gh #7894
+
+    Tests that check a peephole optimization for constant
+    dictionaries in Python 3.10. The bytecode changes when
+    number of elements > 15, which splits the constant dictionary
+    into multiple dictionaries that are joined by a DICT_UPDATE
+    bytecode instruction.
+
+    This optimization modifies the IR to rejoin dictionaries
+    and remove the DICT_UPDATE generated code. This then allows
+    code that depends on literal dictionaries or literal keys
+    to succeed.
+    """
+
+    @skip_unless_py10
+    def test_large_heterogeneous_const_dict(self):
+        """
+        Tests that a function with a large heterogeneous constant
+        dictionary remains a constant.
+        """
+        def const_func():
+            # D is a heterogeneous dictionary
+            # so this code can only compile if
+            # d is constant.
+            d = {
+                "A": 1,
+                "B": 1,
+                "C": 1,
+                "D": 1,
+                "E": 1,
+                "F": 1,
+                "G": 1,
+                "H": 1,
+                "I": 1,
+                "J": 1,
+                "K": 1,
+                "L": 1,
+                "M": 1,
+                "N": 1,
+                "O": 1,
+                "P": 1,
+                "Q": 1,
+                "R": 1,
+                "S": 'a',
+            }
+            return d["S"]
+
+        py_func = const_func
+        cfunc = njit()(const_func)
+        a = py_func()
+        b = cfunc()
+        self.assertEqual(a, b)
+
+    @skip_unless_py10
+    def test_large_heterogeneous_LiteralStrKeyDict_literal_values(self):
+        """Check the literal values for a LiteralStrKeyDict requiring
+        optimizations because it is heterogeneous.
+        """
+
+        def bar(d):
+            ...
+
+        @overload(bar)
+        def ol_bar(d):
+            a = {
+                "A": 1,
+                "B": 1,
+                "C": 1,
+                "D": 1,
+                "E": 1,
+                "F": 1,
+                "G": 1,
+                "H": 1,
+                "I": 1,
+                "J": 1,
+                "K": 1,
+                "L": 1,
+                "M": 1,
+                "N": 1,
+                "O": 1,
+                "P": 1,
+                "Q": 1,
+                "R": 1,
+                "S": 'a',
+            }
+
+            def specific_ty(z):
+                return types.literal(z) if types.maybe_literal(z) else typeof(z)
+            expected = {types.literal(x): specific_ty(y) for x, y in a.items()}
+            self.assertTrue(isinstance(d, types.LiteralStrKeyDict))
+            self.assertEqual(d.literal_value, expected)
+            self.assertEqual(hasattr(d, 'initial_value'), False)
+            return lambda d: d
+
+        @njit
+        def foo():
+            # D is a heterogeneous dictionary
+            # so this code can only compile if
+            # d has the correct literal values.
+            d = {
+                "A": 1,
+                "B": 1,
+                "C": 1,
+                "D": 1,
+                "E": 1,
+                "F": 1,
+                "G": 1,
+                "H": 1,
+                "I": 1,
+                "J": 1,
+                "K": 1,
+                "L": 1,
+                "M": 1,
+                "N": 1,
+                "O": 1,
+                "P": 1,
+                "Q": 1,
+                "R": 1,
+                "S": 'a',
+            }
+            bar(d)
+
+        foo()
+
+    @skip_unless_py10
+    def test_large_heterogeneous_const_keys_dict(self):
+        """
+        Tests that a function with a large heterogeneous constant
+        dictionary remains a constant.
+        """
+        def const_keys_func(a):
+            # D is a heterogeneous dictionary
+            # so this code can only compile if
+            # d has constant keys.
+            d = {
+                "A": 1,
+                "B": 1,
+                "C": 1,
+                "D": 1,
+                "E": 1,
+                "F": 1,
+                "G": 1,
+                "H": 1,
+                "I": 1,
+                "J": 1,
+                "K": 1,
+                "L": 1,
+                "M": 1,
+                "N": 1,
+                "O": 1,
+                "P": 1,
+                "Q": 1,
+                "R": 1,
+                "S": a,
+            }
+            return d["S"]
+
+        py_func = const_keys_func
+        cfunc = njit()(const_keys_func)
+        # str to make the dictionary heterogeneous.
+        value = "a_string"
+        a = py_func(value)
+        b = cfunc(value)
+        self.assertEqual(a, b)
+
+    @skip_unless_py10
+    def test_large_dict_mutation_not_carried(self):
+        """Checks that the optimization for large dictionaries
+        do not incorrectly update initial values due to other
+        mutations.
+        """
+        def bar(d):
+            ...
+
+        @overload(bar)
+        def ol_bar(d):
+            a = {
+                "A": 1,
+                "B": 1,
+                "C": 1,
+                "D": 1,
+                "E": 1,
+                "F": 1,
+                "G": 1,
+                "H": 1,
+                "I": 1,
+                "J": 1,
+                "K": 1,
+                "L": 1,
+                "M": 1,
+                "N": 1,
+                "O": 1,
+                "P": 1,
+                "Q": 1,
+                "R": 1,
+                "S": 7,
+            }
+            if d.initial_value is None:
+                return lambda d: literally(d)
+            self.assertTrue(isinstance(d, types.DictType))
+            self.assertEqual(d.initial_value, a)
+            return lambda d: d
+
+        @njit
+        def foo():
+            # This dictionary is mutated, check the initial_value carries
+            # correctly and is not mutated
+            d = {
+                "A": 1,
+                "B": 1,
+                "C": 1,
+                "D": 1,
+                "E": 1,
+                "F": 1,
+                "G": 1,
+                "H": 1,
+                "I": 1,
+                "J": 1,
+                "K": 1,
+                "L": 1,
+                "M": 1,
+                "N": 1,
+                "O": 1,
+                "P": 1,
+                "Q": 1,
+                "R": 1,
+                "S": 7,
+            }
+            d['X'] = 4
+            bar(d)
+
+        foo()
+
+    @skip_unless_py10
+    def test_usercode_update_use_d2(self):
+        """
+        Tests an example using a regular update is
+        not modified by the optimization.
+        """
+
+        def check_before(x):
+            pass
+
+        def check_after(x):
+            pass
+
+        checked_before = False
+        checked_after = False
+
+        @overload(check_before, prefer_literal=True)
+        def ol_check_before(d):
+            nonlocal checked_before
+            # Typing iteration from d1.update(d2)
+            # may reset the initial values to None,
+            # so we only check on the first iteration.
+            if not checked_before:
+                checked_before = True
+                a = {
+                    "a": 1,
+                    "b": 2,
+                    "c": 3,
+                }
+                self.assertTrue(isinstance(d, types.DictType))
+                self.assertEqual(d.initial_value, a)
+
+            return lambda d: None
+
+        @overload(check_after, prefer_literal=True)
+        def ol_check_after(d):
+            nonlocal checked_after
+            # Typing iteration from d1.update(d2)
+            # may reset the initial values to None,
+            # so we only check on the first iteration.
+            if not checked_after:
+                checked_after = True
+                self.assertTrue(isinstance(d, types.DictType))
+                self.assertTrue(d.initial_value is None)
+
+            return lambda d: None
+
+        def const_dict_func():
+            """
+            Dictionary update between two constant
+            dictionaries. This verifies d2 doesn't
+            get incorrectly removed.
+            """
+            d1 = {
+                "a": 1,
+                "b": 2,
+                "c": 3,
+            }
+            d2 = {
+                "d": 4,
+                "e": 4
+            }
+            check_before(d1)
+            d1.update(d2)
+            check_after(d1)
+            # Create a use of d2 in a new block.
+            if len(d1) > 4:
+                return d2
+            return d1
+
+        py_func = const_dict_func
+        cfunc = njit()(const_dict_func)
+        a = py_func()
+        b = cfunc()
+        self.assertEqual(a, b)
+
+    @skip_unless_py10
+    def test_large_const_dict_inline_controlflow(self):
+        """
+        Tests generating a large dictionary when one of
+        the inputs requires inline control flow
+        has the change suggested in the error message
+        for inlined control flow.
+        """
+        def inline_func(a, flag):
+            # D is a heterogeneous dictionary
+            # so this code can only compile if
+            # d has constant keys.
+            d = {
+                "A": 1,
+                "B": 1,
+                "C": 1,
+                "D": 1,
+                "E": 1,
+                "F": 1,
+                "G": 1,
+                "H": 1 if flag else 2,
+                "I": 1,
+                "J": 1,
+                "K": 1,
+                "L": 1,
+                "M": 1,
+                "N": 1,
+                "O": 1,
+                "P": 1,
+                "Q": 1,
+                "R": 1,
+                "S": a,
+            }
+            return d["S"]
+
+        with self.assertRaises(UnsupportedError) as raises:
+            njit()(inline_func)("a_string", False)
+        self.assertIn(
+            'You can resolve this issue by moving the control flow out',
+            str(raises.exception)
+        )
+
+    @skip_unless_py10
+    def test_large_const_dict_noninline_controlflow(self):
+        """
+        Tests generating large constant dict when one of the
+        inputs has the change suggested in the error message
+        for inlined control flow.
+        """
+        def non_inline_func(a, flag):
+            # D is a heterogeneous dictionary
+            # so this code can only compile if
+            # d has constant keys.
+            val = 1 if flag else 2
+            d = {
+                "A": 1,
+                "B": 1,
+                "C": 1,
+                "D": 1,
+                "E": 1,
+                "F": 1,
+                "G": 1,
+                "H": val,
+                "I": 1,
+                "J": 1,
+                "K": 1,
+                "L": 1,
+                "M": 1,
+                "N": 1,
+                "O": 1,
+                "P": 1,
+                "Q": 1,
+                "R": 1,
+                "S": a,
+            }
+            return d["S"]
+
+        py_func = non_inline_func
+        cfunc = njit()(non_inline_func)
+        value = "a_string"
+        a = py_func(value, False)
+        b = cfunc(value, False)
+        self.assertEqual(a, b)
+
+    @skip_unless_py10
+    def test_fuse_twice_literal_values(self):
+        """
+        Tests that the correct literal values are generated
+        for a dictionary that produces two DICT_UPDATE
+        bytecode entries for the same dictionary.
+        """
+        def bar(d):
+            ...
+
+        @overload(bar)
+        def ol_bar(d):
+            a = {
+                "a1" : 1,
+                "a2" : 2,
+                "a3" : 3,
+                "a4" : 4,
+                "a5" : 5,
+                "a6" : 6,
+                "a7" : 7,
+                "a8" : 8,
+                "a9" : 9,
+                "a10" : 10,
+                "a11" : 11,
+                "a12" : 12,
+                "a13" : 13,
+                "a14" : 14,
+                "a15" : 15,
+                "a16" : 16,
+                "a17" : 17,
+                "a18" : 18,
+                "a19" : 19,
+                "a20" : 20,
+                "a21" : 21,
+                "a22" : 22,
+                "a23" : 23,
+                "a24" : 24,
+                "a25" : 25,
+                "a26" : 26,
+                "a27" : 27,
+                "a28" : 28,
+                "a29" : 29,
+                "a30" : 30,
+                "a31" : 31,
+                "a32" : 32,
+                "a33" : 33,
+                "a34" : 34, # 34 items is the limit of
+                            # (LOAD_CONST + MAP_ADD)^n + DICT_UPDATE
+                "a35" : 35, # 35 Generates an additional BUILD_MAP + DICT_UPDATE
+            }
+            if d.initial_value is None:
+                return lambda d: literally(d)
+            self.assertTrue(isinstance(d, types.DictType))
+            self.assertEqual(d.initial_value, a)
+            return lambda d: d
+
+        @njit
+        def foo():
+            # This dictionary is mutated, check the initial_value carries
+            # correctly and is not mutated
+            d = {
+                "a1" : 1,
+                "a2" : 2,
+                "a3" : 3,
+                "a4" : 4,
+                "a5" : 5,
+                "a6" : 6,
+                "a7" : 7,
+                "a8" : 8,
+                "a9" : 9,
+                "a10" : 10,
+                "a11" : 11,
+                "a12" : 12,
+                "a13" : 13,
+                "a14" : 14,
+                "a15" : 15,
+                "a16" : 16,
+                "a17" : 17,
+                "a18" : 18,
+                "a19" : 19,
+                "a20" : 20,
+                "a21" : 21,
+                "a22" : 22,
+                "a23" : 23,
+                "a24" : 24,
+                "a25" : 25,
+                "a26" : 26,
+                "a27" : 27,
+                "a28" : 28,
+                "a29" : 29,
+                "a30" : 30,
+                "a31" : 31,
+                "a32" : 32,
+                "a33" : 33,
+                "a34" : 34, # 34 items is the limit of
+                            # (LOAD_CONST + MAP_ADD)^n + DICT_UPDATE
+                "a35" : 35, # 35 Generates an additional BUILD_MAP + DICT_UPDATE
+            }
+            bar(d)
+
+        foo()
+
+
 class TestListExtendInStarArgNonTupleIterable(MemoryLeakMixin, TestCase):
     """Test `fn(pos_arg0, pos_arg1, *args)` where args is a non-tuple iterable.