Skip to content

Commit b56b7f7

Browse files
timfennisclaude
andcommitted
docs(vectorization): reconcile RFC with shipped implementation πŸ“
Status flipped from Draft to Implemented. Patched the sections that drifted from what landed: - Worked-example commentary no longer claims the compiler emits unrolled element calls β€” that's RFC step 6, still deferred. - Type-inference table now reflects the actual mechanism (LUB across all candidates' inferred returns) instead of the separate "all scalar" vs "any vec" branches; documents the uniform LUB-collapse on vec return types and the exact-subtype precedence step. - Runtime broadening describes the new `find_overload`/ `dispatch_vec_call` path instead of the deleted try_vectorized_call. - Implementation checklist marked done per step with a ⏸ on step 6. - Open questions resolved or annotated with what shipped (LUB collapse over per-position, "no function found" over a dedicated arity error, empty tuple rejects, etc.). - References updated to point at the current code (and PR #141). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
1 parent e367145 commit b56b7f7

1 file changed

Lines changed: 120 additions & 77 deletions

File tree

β€Ždocs/design/vectorization.mdβ€Ž

Lines changed: 120 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
# RFC: Vectorization scope
22

3-
Status: Draft. Refine before implementing.
3+
Status: Implemented in PR [#141] (commits `df7b11b`, `e367145`). RFC steps
4+
1–5 and 7 shipped together; step 6 (compiler unrolling of `Resolved` vec
5+
candidates) is deferred β€” see the [Compiler optimisation](#compiler-optimisation)
6+
section.
7+
8+
[#141]: https://github.com/timfennis/andy-cpp/pull/141
49

510
## Summary
611

@@ -106,6 +111,16 @@ A vec candidate matches the call's arguments when:
106111
- Each per-position element matches the underlying scalar overload's
107112
parameter type. Scalar args in non-tuple positions broadcast.
108113

114+
Static and runtime checks differ in how they realise the per-position
115+
rule. The analyser collapses each tuple-shaped arg to the LUB of its
116+
element types (so `Tuple<Int, Float>` becomes `Number` for the lookup
117+
sig) and then queries the existing overload-resolution helpers
118+
(`find_function` for exact-subtype, `find_function_candidates` for
119+
loose). The runtime checks every element pair individually against the
120+
scalar's parameter types β€” that's what catches mixed-element tuples
121+
like `(1, "a") + (2, "b")` that today's probe-first dispatch crashes
122+
on at element 1.
123+
109124
This search space drives both `Resolved` and `Dynamic` bindings:
110125

111126
- If the analyser can pin exactly one candidate at compile time (the
@@ -130,8 +145,12 @@ Worked examples on the search space above:
130145

131146
The `(1, 2) + (3, 4)` row is the case worth highlighting. Both arg
132147
types are statically `Tuple<Int, Int>`, so the analyser picks vec
133-
candidate #4 at compile time. The compiler emits the unrolled element
134-
calls directly. No OverloadSet construction, no runtime lookup.
148+
candidate #4 at compile time. Once the [compiler unrolling
149+
optimisation](#compiler-optimisation) lands, this case emits unrolled
150+
element calls directly with no `OverloadSet` construction. As shipped
151+
the candidate is pushed via a single-entry overload set and dispatched
152+
through the same value-level path Dynamic uses; the analyser-side win
153+
is the precise `Tuple<Int, Int>` result type.
135154

136155
The Any rows are where `Dynamic` shows up: the analyser cannot narrow
137156
the search space, so it carries the candidate list forward and lets the
@@ -151,14 +170,21 @@ dispatches over the same list the analyser produced.
151170

152171
### Runtime broadening
153172

154-
Three changes to `try_vectorized_call` in `ndc_vm/src/vm.rs`:
173+
`try_vectorized_call` is gone. `find_overload` walks the augmented
174+
candidate list once and routes the call:
155175

156-
1. Drop the `args != 2` guard. Vec applies for any arity.
157-
2. Replace `as_numeric_tuple` (requires `is_number()` on every element)
158-
with a plain `Object::Tuple` shape check. Element-call dispatch
159-
handles the type lookup.
160-
3. Settle the n-ary broadcast rule: any tuple-shaped arg defines the
161-
axis; all tuples must have equal length; scalars broadcast.
176+
- Scalar candidate: `Function::matches_value_args` checks the args
177+
directly; success returns `Callable::Scalar(func)`.
178+
- Vec candidate: every element pair must satisfy the underlying scalar's
179+
parameter types (not just the first pair β€” the old probe-based
180+
dispatch silently miscoupled mixed-type tuples). Success returns
181+
`Callable::Vec(scalar_fn)`, which `dispatch_vec_call` invokes once per
182+
axis position, broadcasting non-tuple args.
183+
184+
The n-ary broadcast rule: any tuple-shaped arg defines the axis; all
185+
tuple-shaped args must have equal length; scalars broadcast. Empty
186+
tuples and length mismatches decline the vec match and the call falls
187+
through to the regular "no overload found" error.
162188

163189
After these changes:
164190

@@ -196,15 +222,31 @@ Type inference per binding shape:
196222
| binding | result type |
197223
|------------------------------------------|--------------------------------------------|
198224
| `Resolved` to a scalar candidate | overload's declared return |
199-
| `Resolved` to a vec candidate | `Tuple<elem_return, …>` |
200-
| `Dynamic`, all candidates scalar | LUB of declared returns (PR #140 recovery) |
201-
| `Dynamic`, any candidate vectorized | `Any` |
202-
203-
`Dynamic` whose candidate list contains only scalars happens for
204-
regular calls and for operator calls whose args are statically
205-
non-tuple, non-Any. These recover the LUB precision PR #140
206-
pessimised. `Dynamic` lists that mix scalars with vec variants stay at
207-
`Any` because runtime dispatch can pick either kind.
225+
| `Resolved` to a vec candidate | `Tuple<elem_return; max_len>` |
226+
| `Dynamic` | LUB across every candidate's inferred return |
227+
228+
The analyser computes each candidate's contribution (declared return
229+
for scalars, `Tuple<elem_return; max_len>` for vecs where `max_len` is
230+
the statically known broadcast axis) and LUBs them. In the all-scalar
231+
case this recovers the LUB precision PR #140 had to pessimise. In the
232+
mixed-candidate case the type lattice naturally collapses
233+
`LUB(Tuple<…>, scalar)` to `Any` because tuples only join with tuples
234+
of equal arity β€” no special-casing needed.
235+
236+
Vec candidate return type uses **uniform LUB collapse**: each tuple-arg
237+
contributes the LUB of its element types in a single position, and the
238+
result tuple is filled with the scalar overload's return repeated
239+
`max_len` times. So `(Int, Float) + (Float, Int)` β†’ `Tuple<Number,
240+
Number>`, not the per-element-precise `Tuple<Int, Number>`. The
241+
per-position alternative was considered (see [Alternatives](#alternatives-considered))
242+
and rejected for simplicity.
243+
244+
Vec candidate resolution mirrors the scalar path's two-stage lookup:
245+
an exact-subtype `find_function` hit on the synthetic sig wins over
246+
the looser `find_function_candidates` set. This is what makes
247+
`Tuple<Int, Int> - Tuple<Int, Int>` resolve to `Tuple<Int, Int>`
248+
instead of LUB'ing every compatible `-` overload into `Tuple<Number,
249+
Number>`.
208250

209251
### Compiler optimisation
210252

@@ -220,32 +262,34 @@ instead of `OverloadSet` dispatch:
220262
```
221263

222264
The unrolled path skips OverloadSet construction and runtime dispatch.
223-
This optimisation is independent of correctness; it lands after the
224-
soundness work.
265+
This optimisation is independent of correctness; it has not yet landed.
266+
As shipped, `Resolved(vec)` flows through a single-entry overload set
267+
and the same dispatch path as `Dynamic`.
225268

226269
## Implementation
227270

228-
Each step can land on its own:
229-
230-
1. Parser: add `operator_form: bool` to `Expression::Call`. Plumb
231-
through `Clone`/`Debug`.
232-
2. Scope: synthesise vec candidates into the candidate list when
233-
`operator_form` is true. Carry the `vectorized` flag on each
234-
candidate.
235-
3. Runtime: drop the arity-2 and numeric-element guards; replace
236-
`try_vectorized_call` with vec-candidate dispatch inside
237-
`find_overload`. Settle n-ary broadcast.
238-
4. Runtime: error-wrap element-call failures with "while vectorising
239-
`<call>` at index N".
240-
5. Analyser: type-infer per the binding-shape table. Recover LUB when
241-
the candidate list contains only scalars.
242-
6. Compiler: emit the unrolled path when `Resolved` points to a vec
243-
candidate.
244-
7. Delete `BinaryOperator::supports_vectorization` (dead code under
245-
this design).
246-
247-
Steps 1-4 form a "broadening" PR. Steps 5-6 form a "precision
248-
recovery" PR. Step 7 stands alone.
271+
Steps 1–5 and 7 shipped together as one PR. Step 6 is deferred.
272+
273+
1. βœ… Parser: `operator_form: bool` added to `Expression::Call`. Derived
274+
`Clone`/`Debug` propagate it automatically.
275+
2. βœ… Scope: vec candidates synthesised via a per-position LUB-collapsed
276+
sig when `operator_form` is true. Both the loose-compatibility set
277+
and the exact-subtype match are tracked so `Resolved(vec)` mirrors
278+
`Resolved(scalar)`'s precision.
279+
3. βœ… Runtime: `try_vectorized_call` removed; vec dispatch lives in
280+
`find_overload` and `dispatch_vec_call`. N-ary broadcast settled
281+
(any tuple-shaped arg defines the axis; equal-length required;
282+
non-tuple args broadcast).
283+
4. βœ… Runtime: element-call failures wrapped with `"while vectorising
284+
'<name>' at index N"`.
285+
5. βœ… Analyser: type inference produces per-candidate types and LUBs
286+
them, recovering scalar LUB precision and giving precise tuple
287+
types for `Resolved(vec)`.
288+
6. ⏸ Compiler: unrolled emission for `Resolved(vec)` not yet
289+
implemented. Today the analyser-side win (precise return type) lands;
290+
the runtime still dispatches through the overload-set path.
291+
7. βœ… `BinaryOperator::supports_vectorization` and the
292+
`StaticType::supports_vectorization{,_with}` helpers deleted.
249293

250294
## Alternatives considered
251295

@@ -273,55 +317,54 @@ operator names are special.
273317

274318
## Open questions
275319

276-
### P1: silent semantic shift on operator overloads
320+
### P1: silent semantic shift on operator overloads β€” open
277321

278-
Adding `fn +(t: Tuple, u: Tuple) -> X` shifts `(1, 2) + (3, 4)` from
279-
vec to first-class dispatch. The hazard is bounded to operator
322+
Adding `fn +(t: Tuple, u: Tuple) -> X` would shift `(1, 2) + (3, 4)`
323+
from vec to first-class dispatch. The hazard is bounded to operator
280324
overloads because regular calls never vec. The set of operator names
281-
is small, fixed, and known to the parser.
282-
283-
Mitigations to pick from later:
284-
285-
- Stdlib discipline on operator overloads.
286-
- Compiler warning on `Tuple`-typed operator parameters.
287-
288-
### P2: element-call errors lose outer-call context
325+
is small, fixed, and known to the parser. No mitigation shipped;
326+
stdlib discipline is the de-facto safeguard for now.
289327

290-
`(1, "a") + (2, "b")` errors statically today. Under broadening it
291-
errors at element 1 with "no `+` for (String, String)". The outer call
292-
site disappears from the error.
328+
### P2: element-call errors lose outer-call context β€” resolved
293329

294-
Mitigation: error-wrap during vec-candidate invocation (step 4 of
295-
Implementation).
330+
Element-call failures are now wrapped with `"while vectorising
331+
'<name>' at index N: <inner>"`, so the outer call name and the failing
332+
index appear in the error message.
296333

297-
### Length mismatch error
334+
### Length mismatch error β€” accepted as "no function found"
298335

299-
`(1, 2, 3) + (4, 5)` should error with "vec arity mismatch", not "no
300-
function found".
336+
`(1, 2, 3) + (4, 5)`: the vec candidate match declines (length
337+
mismatch on the tuple axis) and the call falls through to the regular
338+
`"no function called '+' found matches the arguments: (Tuple<Int, Int,
339+
Int>, Tuple<Int, Int>)"` error. A dedicated `"vec arity mismatch"`
340+
message would be friendlier but isn't required for correctness.
301341

302-
### Empty tuple
342+
### Empty tuple β€” errors as recommended
303343

304-
`() + ()`: error or return `()`. Recommendation: error.
344+
`() + ()`: `synthetic_vec_sig` rejects empty tuples, so no vec
345+
candidate is synthesised. The call falls through to `"no function
346+
found"` rather than returning `()`.
305347

306-
### Per-position scalar resolution for vec candidates
348+
### Per-position scalar resolution for vec candidates β€” resolved (LUB collapse)
307349

308-
`(Int, Float) + (Float, Int)`: each element pair matches a different
309-
scalar overload. The synthesised vec variant points to a single scalar
310-
candidate; one variant won't cover both pairs. Options: synthesise a
311-
per-position vec candidate (precise tuple type, larger candidate list),
312-
or accept that mixed-element-type cases fall to a less precise
313-
`Tuple<Number, …>` via `Dynamic` dispatch.
350+
`(Int, Float) + (Float, Int)` resolves to a single vec candidate via
351+
per-position LUB, so the result type is `Tuple<Number, Number>` rather
352+
than the per-element-precise `Tuple<Int, Number>`. The precision loss
353+
only affects operator-form calls over genuinely heterogeneous tuples,
354+
which in practice are rare (product-style tuples like `("Tim", 35,
355+
"NL")` never trigger vec at all), so the candidate-list simplicity won
356+
out. Revisit if the imprecision starts to bite.
314357

315-
### Unrolling ceiling
358+
### Unrolling ceiling β€” pending
316359

317-
At what tuple size does compile-time unrolling stop being worth the
318-
bytecode bloat? Pick a threshold (suggested N ≀ 8). Confirm with bench
319-
data.
360+
Relevant once step 6 (compiler unrolling) lands. Suggested threshold
361+
N ≀ 8; confirm with bench data when implementing.
320362

321363
## References
322364

323365
- Issue [#139]: original regression.
324366
- PR [#140]: soundness fix (`Binding::Dynamic` β†’ `Any`).
325-
- `ndc_vm/src/vm.rs::try_vectorized_call`: current vec implementation.
326-
- `ndc_parser/src/operator.rs::BinaryOperator::supports_vectorization`:
327-
dead predicate, deletable under this design.
367+
- PR [#141]: implementation of this RFC.
368+
- `ndc_vm/src/vm.rs::dispatch_vec_call`: runtime vec dispatch.
369+
- `ndc_analyser/src/scope.rs::synthetic_vec_sig`: per-position LUB sig
370+
used for static vec candidate lookup.

0 commit comments

Comments
Β (0)