Dict lookup fast path (perf track 2)#536
Conversation
Track 2: dict find_index_hash now compares simple keys (str/bytes/int/ bool/float/big int) directly against the borrowed stored key via the existing one-sided eq_* helpers, skipping the per-probe clone_with_heap + defer_drop + py_eq round trip. Non-simple keys (instances, tuples) keep the clone + py_eq slow path, preserving reflected-__eq__ semantics. Track 3: - concat_allocate_str replaces format!-based str + str concat at all five py_add sites (one was also cloning both Box<str> operands) - Value::py_hash no longer builds a DefaultHasher on the hot arms; cold arms use a new hash_one helper in hash.rs - str.startswith/endswith borrow affix strings from interns/heap instead of copying every prefix into a Vec<String> - dec_ref's child work stack is pooled on the Heap (capacity-capped), avoiding a malloc per container free Local paired criterion: agg_rows -16%, str_parse -5%. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBcn8RK9Wi4BG3b2aWN4rD
Merging this PR will not alter performance
Comparing Footnotes
|
Codecov Results 📊❌ Patch coverage is 0.00%. Project has 40869 uncovered lines. Files with missing lines (1)
Coverage diff@@ Coverage Diff @@
## main #PR +/-##
==========================================
+ Coverage 49.65% 49.65% —%
==========================================
Files 316 316 —
Lines 81113 81169 +56
Branches 172509 172661 +152
==========================================
+ Hits 40276 40300 +24
- Misses 40837 40869 +32
- Partials 3097 3097 —Generated by Codecov Action |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
1 issue found across 7 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="crates/monty/src/types/dict.rs">
<violation number="1" location="crates/monty/src/types/dict.rs:267">
P2: Dict lookup can miss equal big-int keys when the stored key is `InternLongInt`, because the new simple-key path treats that representation as "simple" but converts `NotImplemented` to `false` instead of falling back to reflected `py_eq`. Consider excluding `InternLongInt` from `is_simple_key` (or teaching the one-sided helpers to compare against it) so this path preserves existing equality semantics.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| | Value::Float(_) | ||
| | Value::InternString(_) | ||
| | Value::InternBytes(_) | ||
| | Value::InternLongInt(_) => true, |
There was a problem hiding this comment.
P2: Dict lookup can miss equal big-int keys when the stored key is InternLongInt, because the new simple-key path treats that representation as "simple" but converts NotImplemented to false instead of falling back to reflected py_eq. Consider excluding InternLongInt from is_simple_key (or teaching the one-sided helpers to compare against it) so this path preserves existing equality semantics.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/monty/src/types/dict.rs, line 267:
<comment>Dict lookup can miss equal big-int keys when the stored key is `InternLongInt`, because the new simple-key path treats that representation as "simple" but converts `NotImplemented` to `false` instead of falling back to reflected `py_eq`. Consider excluding `InternLongInt` from `is_simple_key` (or teaching the one-sided helpers to compare against it) so this path preserves existing equality semantics.</comment>
<file context>
@@ -222,6 +222,57 @@ fn json_key_equals_str(key: &Value, expected: &str, heap: &Heap<impl ResourceTra
+ | Value::Float(_)
+ | Value::InternString(_)
+ | Value::InternBytes(_)
+ | Value::InternLongInt(_) => true,
+ Value::Ref(id) => matches!(
+ vm.heap.get(*id),
</file context>
CodSpeed instrumentation on the PR run showed the pooled stack's mem::take/restore runs on every dec_ref call (most of which only decrement and free nothing), costing more than the Vec it saved: json_dumps dec_ref went 3.5ms -> 4.6ms (+4.9% bench), agg_rows dec_ref 7.6ms -> 7.9ms, with replace<Vec<HeapId>> alone at 2.8% of json_dumps. Vec::new() is free until children are actually pushed, so the malloc only ever occurred on container frees - a much rarer event than the per-call take/restore. heap.rs is back to main except for a comment recording this finding. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBcn8RK9Wi4BG3b2aWN4rD
|
Reviewed the first CodSpeed run (6a4bb88b vs main 6a4ba434) via the flamegraphs: Wins confirmed — Regression found and reverted — the dec_ref work-stack pooling was net negative: the |
|
CodSpeed run on the revert commit (6a4bbdc5) confirms it: the dec_ref-pooling regressions are gone — json_dumps 34→32.8ms (now +1.2% vs main, within cross-CPU noise), pair_tuples/builtin_args/list_append_int back to parity — while the tracks-2/3 wins held or improved slightly: agg_rows −7.9%, loop_mod_13 −10.7%, str_parse −5.6% vs main, with re_extract −3.5% and fstring_report −1.6% as side benefits. fib unchanged as expected (that's track 4). Note both PR runs executed on a different CPU model than the main baseline run, so sub-2% deltas aren't meaningful. |
CodSpeed flamegraphs on this PR showed simple_key_eq, is_simple_key and the one-sided eq_* helpers staying outlined on the instrumented build, so every candidate comparison in find_index_hash paid three function calls — visible as a +44% self-time shift in dict_comp. Local builds already inline them (adding the hints measures neutral on wall time); #[inline] makes the instrumented/EPYC build match. Heavier restructurings were tried and measured consistently worse: equality inside the probe closure (~5% slower on dict_comp — the fat closure hurts codegen and hashbrown's single-group probe makes early exit worthless) and a hoisted borrowed-key enum (~2% slower — enum materialization costs more than the double-match it removes). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBcn8RK9Wi4BG3b2aWN4rD
The string concat / py_hash / startswith changes now live on perf-wins-004 so this PR reviews as the dict lookup change alone. This branch keeps track 2 (find_index_hash simple-key fast path, its dict__ops test cases) and the #[inline] hints on the eq_* helpers the fast path calls per candidate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBcn8RK9Wi4BG3b2aWN4rD
There was a problem hiding this comment.
2 issues found across 5 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="crates/monty/src/types/str.rs">
<violation number="1">
P3: `startswith`/`endswith` now copy every prefix or suffix before checking it, so large tuple affixes pay avoidable allocations even when the first item matches. Consider preserving the existing args guard and validating/iterating borrowed interned or heap strings instead of returning `Vec<String>`.</violation>
<violation number="2">
P3: Heap-string concatenation now does two extra full-string allocations before building the result. Borrowing both operands and filling one `String::with_capacity(self.len() + other.len())` keeps `str + str` on the cheap path without changing semantics.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
davidhewitt
left a comment
There was a problem hiding this comment.
Seems generally a good idea, potentially worth applying to other operations like sets, and maybe even in list methods like count or index.
| "ask": [ | ||
| "Bash(git push:*)", | ||
| "Bash(git commit:*)" | ||
| "Bash(git push:*)" |
There was a problem hiding this comment.
FWIW this sort of thing might be better in the settings.local.json or your user-level settings.
Dict lookup without candidate-key clone
find_index_hashnow compares simple keys (str/bytes/int/bool/float/big int, interned or heap) directly against the borrowed stored key via the existing one-sidedeq_str/eq_i64/eq_f64/eq_bigint/eq_byteshelpers (which take&VMimmutably), skipping the per-probeclone_with_heap+defer_drop+py_eqround trip that existed only to satisfy the borrow checker. Candidates where either side is non-simple (instances, tuples,None, …) keep the clone +py_eqslow path, so reflected-__eq__semantics are unchanged. Benefitsgetitem,setitem,in, anddict.getalike.The
eq_*helpers andsimple_key_eq/is_simple_keyare marked#[inline]: on the instrumented CodSpeed build they stayed outlined and the per-candidate call overhead showed up as afind_index_hashself-time regression in the dict-heavy flamegraphs (neutral on local builds, which already inline them).Two heavier restructurings were tried and measured consistently worse locally, so they were deliberately not taken: equality inside the hashbrown probe closure (~5% slower on dict_comp — the fat closure hurts codegen and the single-group SIMD probe makes early exit worthless) and a hoisted borrowed-key enum (~2% slower — enum materialization costs more than the double-match it removes).
Tests
New cross-type dict-key section in
test_cases/dict__ops.py: int/bool/float/bigint key equivalence (incl. exact2.0**100↔2**100), bool overwrite keeping the original key object, runtime-built str keys, distinct int/str/bytes/tuple keys, NaN keys. Verified against both CPython and Monty.Green locally:
test-cases,test-memory-model-checks,test-ref-count-return,test-no-features,lint-rs,lint-py.Benchmarks
Measured while this PR still included track 3 (CodSpeed vs main): agg_rows −7.9%, with the dict lookup being the dominant contributor per the flamegraphs (the probe's clone+py_eq path disappeared). The next CodSpeed run on this branch gives the isolated number.
Note
This PR originally also carried track 3 (string cheap wins:
concat_allocate_str, thepy_hashhot-arm rewrite, borrowedstartswith/endswithaffixes). That's been split out to theperf-wins-004branch so each change can be reviewed and measured on its own.🤖 Generated with Claude Code
https://claude.ai/code/session_01QBcn8RK9Wi4BG3b2aWN4rD
Summary by cubic
Speeds up dict lookups by adding a simple-key fast path that compares
str/bytes/int/bool/float/big int keys in place and skips per-probe cloning. Non-simple keys still usepy_eqto preserve reflected__eq__; local agg_rows −16%.find_index_hash: in-place compare for simple keys via inlinedeq_*; skip clone +defer_drop; fallback keeps reflected equality.eq_*helpers#[inline]; expanded dict-key tests (numeric-tower equivalence, runtime-built vs literalstr, NaN, key-type separation).Written for commit ec08c2a. Summary will update on new commits.