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+
109124This 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
131146The ` (1, 2) + (3, 4) ` row is the case worth highlighting. Both arg
132147types 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
136155The Any rows are where ` Dynamic ` shows up: the analyser cannot narrow
137156the 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
163189After 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
222264The 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
280324overloads 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