Fast path for hot property getters: stop importing mcrfpy per call; closes #331

Replace the PyImport_ImportModule("mcrfpy") + PyObject_GetAttrString +
full type-call ceremony with direct &mcrfpydef::PyXType references
(safe since all PyTypeObjects are inline C++17 definitions) in every
non-init call site:

- UIDrawable: get_pos/get_origin/get_global_pos now allocate via
  PyVector(...).pyObject(); set_pos/set_origin check the Vector type
  directly.
- GridLayers: ColorLayer at/subscript wrap via PyColor(...).pyObject()
  (also fixes a module refcount leak per read); set/fill/fill_rect,
  draw_fov/apply_perspective color parsing, Entity/Texture/HeightMap
  instance checks, and TileLayer texture get/set use direct type refs.
- UIGrid init layers=, add_layer/remove_layer/layer(), Grid.layers
  getter: direct ColorLayer/TileLayer type refs.
- PyTileSetFile.to_texture, PyLdtkProject.tileset: direct type refs.

Benchmark (tests/benchmarks/issue_331_property_read_bench.py, 200k
reads, release build):
  frame.pos              559 ns -> 65 ns/read  (8.6x)
  frame.origin           571 ns -> 64 ns/read  (8.9x)
  frame.global_position  589 ns -> 66 ns/read  (8.9x)
  ColorLayer.at          608 ns -> 262 ns/read (2.3x)

Test suite: 304/304 pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DK5DHgRDNGmpN54vnEHAot
This commit is contained in:
John McCardle 2026-07-08 21:58:50 -04:00
commit 182da6230d
8 changed files with 113 additions and 342 deletions

View file

@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""
Issue #331: micro-benchmark for hot property getters.
Times tight-loop reads of properties that (pre-fix) executed
PyImport_ImportModule("mcrfpy") + PyObject_GetAttrString + full type call
per read, versus the direct tp_alloc fast path (post-fix).
Direct-execution benchmark: no game loop needed, property reads work
immediately after object construction.
Usage:
./mcrogueface --headless --exec tests/benchmarks/issue_331_property_read_bench.py
"""
import mcrfpy
import sys
import time
N = 200_000
def bench(label, fn):
start = time.perf_counter()
fn()
elapsed = time.perf_counter() - start
per_read_ns = elapsed / N * 1e9
print(f"{label:24s} {elapsed*1000:8.1f} ms total {per_read_ns:8.0f} ns/read")
return per_read_ns
def main():
frame = mcrfpy.Frame(pos=(10, 20), size=(100, 100))
layer = mcrfpy.ColorLayer(name="bench", z_index=0)
grid = mcrfpy.Grid(grid_size=(8, 8), layers=[layer])
layer.set((3, 3), (10, 20, 30, 255))
def read_pos():
for _ in range(N):
frame.pos
def read_origin():
for _ in range(N):
frame.origin
def read_global_pos():
for _ in range(N):
frame.global_position
def read_layer_at():
for _ in range(N):
layer.at((3, 3))
print(f"issue #331 property-read benchmark, N={N}")
results = {
"pos": bench("frame.pos", read_pos),
"origin": bench("frame.origin", read_origin),
"global_pos": bench("frame.global_position", read_global_pos),
"layer_at": bench("ColorLayer.at", read_layer_at),
}
# Sanity: values must be correct regardless of construction path
p = frame.pos
assert (p.x, p.y) == (10.0, 20.0), f"pos wrong: {p}"
c = layer.at((3, 3))
assert (c.r, c.g, c.b) == (10, 20, 30), f"color wrong: {c}"
print("PASS")
return results
if __name__ == "__main__":
main()
sys.exit(0)