Skip to content

Commit 9c8611d

Browse files
timfennisclaude
andcommitted
Perf: P3/P4/P5 — close_upvalues single pass, vectorization deferred alloc, borrow candidates
- P3: replace close+retain two-pass with single retain_mut (~12% on closures bench) - P4: inline shape/probe check before building vectorization_pairs Vec - P5: borrow &[ResolvedVar] from stack instead of cloning Vec on every 2-arg dispatch - R4: OverloadSet Hash+PartialEq now panic instead of silently misbehaving Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
1 parent 3f76f86 commit 9c8611d

3 files changed

Lines changed: 54 additions & 31 deletions

File tree

ndc_vm/REVIEW.md

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -59,40 +59,46 @@ release). Currently guarded only by the convention that the compiler always emit
5959

6060
---
6161

62-
### R4 — `OverloadSet` hashing is broken
62+
### ~~R4 — `OverloadSet` hashing is broken~~ ✅ Fixed
6363
**File:** `value/mod.rs:838` | **Axes:** Bug, Idiomatic Rust
6464

6565
All `OverloadSet` values hash to the discriminant byte `10` regardless of contents,
6666
while `PartialEq` compares contents. This violates the `Hash`/`Eq` contract and
6767
would silently corrupt any `HashMap<Value, _>` keyed on an overload set.
6868

69-
**Fix:** Hash by pointer identity (consistent with `Iterator` at line 844), or
70-
implement `Hash` for `ResolvedVar` in `ndc_parser`.
69+
**Investigation:** `OverloadSet` is only ever emitted as a transient callee constant
70+
consumed immediately by `Call``get_binding_any` always returns a single `Resolved`
71+
binding, so OverloadSets cannot currently reach map keys through normal NDC code.
72+
73+
**Fix:** Both `Hash` and `PartialEq` now panic with `"OverloadSet cannot be used as a
74+
map key"`, making any future regression loudly visible rather than silently corrupting.
7175

7276
---
7377

7478
## 🟡 Minor
7579

76-
### P3 — `close_upvalues` makes two passes
80+
### ~~P3 — `close_upvalues` makes two passes~~ ✅ Fixed
7781
**File:** `vm.rs:514` | **Axes:** Performance
7882

7983
Close-then-`retain` traverses `open_upvalues` twice and borrows every cell twice.
8084

8185
**Fix:** Single `retain_mut` pass that both closes and filters simultaneously.
86+
**Result:** ~12% speedup on closure-heavy benchmarks.
8287

8388
---
8489

85-
### P4 — `vectorization_pairs` eagerly allocates even when vectorization fails
90+
### ~~P4 — `vectorization_pairs` eagerly allocates even when vectorization fails~~ ✅ Fixed
8691
**File:** `vm.rs:1003` | **Axes:** Performance
8792

8893
Builds a `Vec<(Value, Value)>` with full element clones before the type/shape check
8994
can fail. Every `Rc` clone bumps a refcount even if the vec is immediately dropped.
9095

91-
**Fix:** Check shape/types first; build the pairs vec only once vectorization is confirmed.
96+
**Fix:** Inline shape check in `try_vectorized_call`: build a two-element probe to
97+
confirm overload match, then call `vectorization_pairs` only once confirmed.
9298

9399
---
94100

95-
### P5 — `candidates` cloned on every 2-arg dynamic dispatch
101+
### ~~P5 — `candidates` cloned on every 2-arg dynamic dispatch~~ ✅ Fixed
96102
**File:** `vm.rs:836` | **Axes:** Performance
97103

98104
```rust
@@ -101,7 +107,8 @@ Object::OverloadSet(candidates) => candidates.clone(),
101107

102108
Clones `Vec<ResolvedVar>` unconditionally for every dynamic binary call.
103109

104-
**Fix:** Pass `&[ResolvedVar]` through to `find_overload` and `try_vectorized_call`.
110+
**Fix:** Scoped the shared borrows so `candidates: &[ResolvedVar]` is borrowed from
111+
the stack instead; all borrows drop before the `&mut self` calls below.
105112

106113
---
107114

ndc_vm/src/value/mod.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -740,7 +740,9 @@ impl PartialEq for Object {
740740
_ => false,
741741
}
742742
}
743-
(Self::OverloadSet(a), Self::OverloadSet(b)) => a == b,
743+
(Self::OverloadSet(_), Self::OverloadSet(_)) => {
744+
panic!("OverloadSet cannot be used as a map key")
745+
}
744746
(Self::Iterator(a), Self::Iterator(b)) => {
745747
// Compare iterators by pointer identity
746748
std::ptr::addr_eq(Rc::as_ptr(a), Rc::as_ptr(b))
@@ -836,9 +838,7 @@ impl Hash for Object {
836838
}
837839
}
838840
Self::OverloadSet(_) => {
839-
// OverloadSet hashing is skipped since ResolvedVar doesn't implement Hash
840-
// Treat them as opaque value types, hash by pointer identity
841-
state.write_u8(10);
841+
panic!("OverloadSet cannot be used as a map key")
842842
}
843843
Self::Iterator(iter) => {
844844
state.write_u8(11);

ndc_vm/src/vm.rs

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -530,17 +530,16 @@ impl Vm {
530530
/// starting at `frame_pointer`. Called just before a frame's stack window
531531
/// is reclaimed so that closures retaining those cells keep live copies.
532532
fn close_upvalues(&mut self, frame_pointer: usize) {
533-
for cell in &self.open_upvalues {
533+
self.open_upvalues.retain_mut(|cell| {
534534
let mut borrow = cell.borrow_mut();
535535
if let UpvalueCell::Open(slot) = *borrow
536536
&& slot >= frame_pointer
537537
{
538538
*borrow = UpvalueCell::Closed(self.stack[slot].clone());
539+
return false;
539540
}
540-
}
541-
// Remove the cells we just closed; outer-frame Open cells stay.
542-
self.open_upvalues
543-
.retain(|c| matches!(*c.borrow(), UpvalueCell::Open(_)));
541+
true
542+
});
544543
}
545544

546545
fn dispatch_call(&mut self, func: Function, args: usize) -> Result<(), VmError> {
@@ -799,25 +798,42 @@ impl Vm {
799798
}
800799

801800
let callee_idx = self.stack.len() - args - 1;
802-
let candidates = match &self.stack[callee_idx] {
803-
Value::Object(obj) => match obj.as_ref() {
804-
Object::OverloadSet(candidates) => candidates.clone(),
801+
802+
// Use a block to scope all shared borrows of self.stack so they are
803+
// dropped before the &mut self calls (call_callback, truncate) below.
804+
let (inner_fn, left, right) = {
805+
// P5: borrow candidates rather than cloning the Vec.
806+
let candidates: &[ResolvedVar] = match &self.stack[callee_idx] {
807+
Value::Object(obj) => match obj.as_ref() {
808+
Object::OverloadSet(candidates) => candidates,
809+
_ => return Ok(None),
810+
},
805811
_ => return Ok(None),
806-
},
807-
_ => return Ok(None),
808-
};
812+
};
809813

810-
let left = self.stack[self.stack.len() - 2].clone();
811-
let right = self.stack[self.stack.len() - 1].clone();
814+
let left = &self.stack[self.stack.len() - 2];
815+
let right = &self.stack[self.stack.len() - 1];
812816

813-
let Some(pairs) = vectorization_pairs(&left, &right) else {
814-
return Ok(None);
817+
// P4: check shape and build a two-element probe for overload lookup
818+
// before committing to a full Vec allocation.
819+
let left_tup = as_numeric_tuple(left);
820+
let right_tup = as_numeric_tuple(right);
821+
let probe: [Value; 2] = match (left_tup, right_tup) {
822+
(Some(ls), Some(rs)) if ls.len() == rs.len() => [ls[0].clone(), rs[0].clone()],
823+
(None, Some(rs)) if left.is_number() => [left.clone(), rs[0].clone()],
824+
(Some(ls), None) if right.is_number() => [ls[0].clone(), right.clone()],
825+
_ => return Ok(None),
826+
};
827+
828+
let Some(inner_fn) = self.find_overload(candidates, &probe) else {
829+
return Ok(None);
830+
};
831+
832+
(inner_fn, left.clone(), right.clone())
815833
};
816834

817-
// Use the first pair's elements to find a matching inner function.
818-
let first_elems = [pairs[0].0.clone(), pairs[0].1.clone()];
819-
let Some(inner_fn) = self.find_overload(&candidates, &first_elems) else {
820-
return Ok(None);
835+
let Some(pairs) = vectorization_pairs(&left, &right) else {
836+
unreachable!("shape was already verified above")
821837
};
822838

823839
let mut results = Vec::with_capacity(pairs.len());

0 commit comments

Comments
 (0)