Skip to content

py/map: Convert map implementation to preserve insertion order. #10

Open
andrewleech wants to merge 6 commits intomasterfrom
py-map-ordered
Open

py/map: Convert map implementation to preserve insertion order. #10
andrewleech wants to merge 6 commits intomasterfrom
py-map-ordered

Conversation

@andrewleech
Copy link
Owner

Summary

py/map: Convert map implementation to preserve insertion order.

Testing

TBD

Trade-offs and Alternatives

TBD

dpgeorge and others added 6 commits January 13, 2026 22:00
TODO:
- make it optional at compile-time
- make OrderedDict use this new implementation
- implement more efficient deletion
- profile performance and memory use

Signed-off-by: Damien George <damien@micropython.org>
This commit improves the ordered dict implementation from PR micropython#6173:

1. O(1) len() tracking: Add `filled` field to mp_map_t to track actual
   element count separately from `used` (insertion index). This avoids
   O(n) scan when computing dict length.

2. Large dict support: Add MICROPY_PY_MAP_LARGE config option to support
   dicts with >65535 elements using uint32_t hash table indices.

3. Compaction on delete: Add mp_map_compact() that triggers when
   tombstones exceed 50% of live entries, preventing unbounded memory
   growth from repeated delete+insert cycles. The table can now shrink
   when entries are deleted.

Signed-off-by: Andrew Leech <andrew.leech@planetinnovation.com.au>
Test dict compaction behavior:
- dict_compact_order.py: Verify insertion order preserved after compaction
- dict_compact_threshold.py: Test threshold boundary and deletion patterns
- dict_compact_empty.py: Empty dict edge cases
- stress/dict_compact.py: Stress test for memory growth from add/delete

Signed-off-by: Andrew Leech <andrew.leech@planetinnovation.com.au>
- Remove duplicate mp_const_empty_map const (now a macro in obj.h)
- Use 'static' instead of 'STATIC' for mp_map_rehash
- Add missing .filled = 0 to mp_const_empty_dict_obj
When heap is locked and the dense array is full but contains tombstones,
reclaim the tail tombstone to allow adding new entries without allocation.
This fixes heap-locked exception handling where Python 3 semantics delete
the exception variable after each except block, creating tombstones.

Two changes:
1. Treat hash table entries pointing beyond `used` as empty (stale entries)
2. On hash probe wrap-around, if last slot is tombstone, decrement `used`

Controlled by MICROPY_PY_MAP_REUSE_TAIL_TOMBSTONE (default enabled, ~50 bytes).

Signed-off-by: Andrew Leech <andrew.leech@planetinnovation.com.au>
Signed-off-by: Andrew Leech <andrew.leech@planetinnovation.com.au>
@github-actions
Copy link

Code size report:

Reference:  zephyr/mpconfigport: Remove duplicate builtins.open definition. [1ab9b66]
Comparison: docs: Add development log for ordered dict implementation. [merge of 3d1157b]
  mpy-cross:  +912 +0.242% [incl +224(data) +16(bss)]
   bare-arm:  +436 +0.773% [incl +8(bss)]
minimal x86: +1032 +0.551% [incl +192(data)]
   unix x64: +2480 +0.290% standard[incl +1616(data) +32(bss)]
      stm32:  +804 +0.204% PYBV10[incl +8(bss)]
      esp32:  +888 +0.051% ESP32_GENERIC[incl +448(data) +8(bss)]
     mimxrt:  +752 +0.200% TEENSY40
        rp2:  +872 +0.095% RPI_PICO_W[incl +8(bss)]
       samd:  +736 +0.271% ADAFRUIT_ITSYBITSY_M4_EXPRESS[incl +8(bss)]
  qemu rv32:  +760 +0.167% VIRT_RV32[incl +8(bss)]

Repository owner deleted a comment from mpy-reviewer bot Feb 25, 2026
@andrewleech
Copy link
Owner Author

/review

Copy link

@mpy-reviewer mpy-reviewer bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Several blocking issues need addressing before this can land. The most critical are: an out-of-bounds read in mp_map_init_copy that ASAN catches on dict_fixed.py, a memory leak from m_del being called with the wrong byte size on every map dealloc/rehash, and heap-locked allocation failures in settrace_stackless (heapalloc.py, extreme_exc.py, heapalloc_super.py crash with MemoryError: memory allocation failed, heap is locked). DEVLOG.md should not be committed to the repository. Also missing: a MICROPY_PY_MAP_ORDERED compile-time toggle to select between implementations (noted as TODO in the DEVLOG itself).

CI Issues: sanitize_address fails on basics/dict_fixed.py (out-of-bounds read in mp_map_init_copy). settrace_stackless fails on micropython/heapalloc.py, micropython/extreme_exc.py, and micropython/heapalloc_super.py — all crash with MemoryError: memory allocation failed, heap is locked, indicating the new implementation triggers heap allocation during previously allocation-free operations.

@@ -0,0 +1,314 @@
# MicroPython Ordered Dict Development Log
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't commit this to the repo.

map->is_ordered = src->is_ordered;
size_t n = MP_MAP_TABLE_BYTE_SIZE(map->alloc);
map->table = m_malloc0(n);
memcpy(map->table, src->table, n);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

src->table for an ordered map (including ROM maps) has only src->alloc * sizeof(mp_map_elem_t) bytes — there are no hash indices appended. Copying MP_MAP_TABLE_BYTE_SIZE(src->alloc) bytes is an out-of-bounds read. This is what ASAN catches in dict_fixed.py. Need:

size_t n = src->is_ordered
    ? src->alloc * sizeof(mp_map_elem_t)
    : MP_MAP_TABLE_BYTE_SIZE(src->alloc);

mp_map_lookup(map, old_table[i].key, MP_MAP_LOOKUP_ADD_IF_NOT_FOUND)->value = old_table[i].value;
}
}
m_del(mp_map_elem_t, old_table, old_alloc);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m_del(mp_map_elem_t, old_table, old_alloc) frees old_alloc * sizeof(mp_map_elem_t) bytes, but the allocation was MP_MAP_TABLE_BYTE_SIZE(old_alloc) bytes. Memory leak of MP_MAP_INDEX_SIZE(old_alloc) * old_alloc bytes on every rehash. Same bug in mp_map_compact and in mp_map_deinit (which uses the pre-existing m_del call that now also has the wrong size). Need m_free(old_table, MP_MAP_TABLE_BYTE_SIZE(old_alloc)) at all three sites.

// Skip if dict is now empty (no point compacting an empty dict).
if (map->filled > 0 && map->used - map->filled > map->filled / 2) {
mp_map_compact(map);
// After compact, original slot is invalid; return value in first empty slot
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After mp_map_compact(), map->used == map->filled and &map->table[map->used] is the next insertion slot. Storing the deleted value there with key = MP_OBJ_NULL is fragile — any reentrant lookup that inserts will clobber that slot before the caller reads slot->value. Pass the value out via an out-param or save it before calling compact.

void mp_map_init(mp_map_t *map, size_t n);
void mp_map_init_fixed_table(mp_map_t *map, size_t n, const mp_obj_t *table);
void mp_map_init_copy(mp_map_t *map, const mp_map_t *src);
mp_map_t *mp_map_new(size_t n);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mp_map_new and mp_map_free are declared here but not defined anywhere in this diff.

// Macros and functions to deal with key/value table and hash table.
// map->table points to the key/value table, then the hash table follows,
// which can be uint8_t, uint16_t, or uint32_t depending on allocation size.
#define MP_MAP_IS_UINT8(alloc) ((alloc) < 255)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be < 256. With 1-based indexing, alloc=255 stores max index 255 which fits in uint8_t.

Suggested change
#define MP_MAP_IS_UINT8(alloc) ((alloc) < 255)
#define MP_MAP_IS_UINT8(alloc) ((alloc) < 256)


// Whether to support dicts with >65535 elements (requires uint32_t hash indices)
#ifndef MICROPY_PY_MAP_LARGE
#define MICROPY_PY_MAP_LARGE (1)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should default to 0 — most embedded targets won't need >65535-element dicts, and enabling this by default adds the uint32_t index paths to all builds.

size_t used : (8 * sizeof(size_t) - 3);
size_t used : (8 * sizeof(size_t) - 3); // next insertion index in dense array
size_t alloc;
size_t filled; // number of non-deleted entries (for O(1) len())
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding a full size_t filled costs +4 or +8 bytes per mp_map_t. Every module dict, class dict, and instance dict pays this. Is there a way to track it more cheaply, e.g., derive it from used minus the tombstone count?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants