Fix(memorization) issue 710 py lsp memoization#732
Conversation
Signed-off-by: LA-10 <lashuha@uwaterloo.com>
Signed-off-by: LA-10 <lashuha@uwaterloo.com>
Signed-off-by: LA-10 <lashuha@uwaterloo.com>
|
Huge thanks for opening this PR and for the work you put into it. The maintainer shop is currently full, so this may sit for a bit before it gets a proper review. We will come back to this as soon as possible with real feedback; I wanted to make sure it did not sit unacknowledged in the meantime. |
|
Thank you — your diagnosis is the correct one for #710, and we verified it independently: py_eval_expr_type evaluates each attribute receiver twice (container special-case + general attribute path), giving O(2^N) re-evaluation on chained calls; the recently-merged walker caps (#779) did not and could not address this. Memoization is the right generalized fix. One blocking correctness issue before merge:
Update: to keep momentum on the bug backlog, we're going to carry the changes above over the line ourselves shortly — a distilled follow-up on current main implementing the notes in this thread, with you credited as |
|
Thank you — your diagnosis WAS the fix for #710: we independently verified the double receiver evaluation gives O(2^N) re-evaluation on chained calls, and memoization is the right cure. We carried it over the line as 90f59c5 (PR #809) with you credited as co-author, with one correction from the review: the cache key is node identity (TSNode.id) rather than start byte — leftmost descendants share start bytes, and a probe experiment confirmed the collision silently mis-resolves heterogeneous chains like client.session().execute(). The distill also adds a scope-generation flush (lambda re-walks under different bindings would otherwise serve stale types), the depth/steps guards from #758, and never-caches truncated results. #710 closes — it would be great if you could retest on main. Closing this in favor of the distill — thanks again for the rigorous write-up! |
…al hang) py_eval_expr_type evaluated a call node's attribute receiver twice (once in the container special-case, once in the general attribute path), so an N-link chained call cost O(2^N) evaluations — real-world ~65-link builder chains hung indexing for hours (DeusData#710). The evaluator also recursed once per expression nesting level with no depth guard, so a pathologically deep expression overflowed the native stack (DeusData#720). Three guard layers, mirroring c_eval_expr_type's design in c_lsp.c: 1. Memoization: py_eval_expr_type is now a wrapper around the renamed py_eval_expr_type_uncached. Results are cached per node in an open-addressing table on PyLSPContext (arena-allocated, cold per file), keyed by node identity (TSNode.id) — NOT the start byte, since every leftmost descendant of a chain shares it and byte-keyed entries alias distinct nodes into silently wrong resolutions. Entries carry a scope generation; every scope bind/restore bumps it (O(1) whole-cache flush), so re-evaluation under changed bindings (lambda re-walks, isinstance narrowing) behaves exactly as before. Inserts are load-bounded (<75%) and rejected when growth fails, so a full table can never spin. 2. Depth cap: PY_LSP_MAX_EVAL_DEPTH 256 (same as C_EVAL_DEPTH_LIMIT); past the cap the evaluator returns unknown instead of overflowing the stack. Truncated results — including any ancestor of a truncation — are never cached, so a depth- or budget-limited `unknown` cannot poison post-truncation reuse. 3. Step budget: PY_EVAL_MAX_STEPS_PER_FILE 10000 (mirrors C_EVAL_MAX_STEPS_PER_FILE); pathological files degrade to unknown instead of stalling indexing. Cache hits don't consume budget. Distills PR DeusData#732 (memoization; fixed the start-byte cache key and the missing staleness handling) and PR DeusData#758 (depth cap; raised 64 -> 256 to match the sibling LSPs, added the never-cache-truncated ordering), and adds the step budget plus reproduce-first tests: a 40-link chain that must resolve its final link, a heterogeneous-receiver chain that fails under byte keying, a 30k-deep expression crash guard, and a budget degradation guard. Fixes DeusData#710. Fixes DeusData#720. Co-authored-by: LA-10 <lashuha@uwaterloo.com> Co-authored-by: Dustin Persek <dustin.persek@protonmail.com> Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
|
Confirmed resolved on main (90f59c5). Built from source and re-ran the build_graph StateGraph chain repro through index_repository. On the pre-fix release binary the definitions pass pins a core and runs to timeout (killed at 6.4 CPU-s); on the fixed build the same input completes cleanly |
What does this PR do?
Fixes #710 index_repository hangs during the definitions pass on Python files with deeply chained method calls, consuming 800+ s and ~1.4 GB with no progress.
py_eval_expr_type (internal/cbm/lsp/py_lsp.c) recursively evaluates expression types with no memoization. Chained calls cause the same sub-chains to be re-evaluated at every level roughly O(2^N) in chain depth
Memoize py_eval_expr_type, keyed by node start byte. Each sub-expression is typed once, collapsing chain evaluation to O(depth). The original body is renamed _uncached and wrapped by a thin memoizing entry results identical, only redundant recomputation removed. Cache is arena-allocated per file (freed automatically).
Added pylsp_issue710_deep_call_chain_no_hang to tests/test_py_lsp.c a ~40-link chain that hangs on the old code and passes on the fix.
Checklist
git commit -s) — required, CI rejectsunsigned commits (DCO, see CONTRIBUTING.md)
make -f Makefile.cbm test)make -f Makefile.cbm lint-ci)