Skip to content

Commit 8b212f3

Browse files
Implement interface vtable simplification for function-typed fields and add regression test
1 parent 441ff7b commit 8b212f3

5 files changed

Lines changed: 253 additions & 15 deletions

File tree

tslang/docs/interface-vtable-simplification-design.md

Lines changed: 80 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
# Interface vtable simplification: design
22

3-
Status: **PR 1 (§4 + §5) implemented, full suite green (716/716)**. §3
4-
(constant vtables for capture-free literal methods) not yet started. Follow-up
5-
to PR #251 (heap-allocated patched vtable) and PR #252 (canonical slot
6-
numbering, `fix-interface-vtable-index-mismatch@be2c9620`). All anchors below
7-
verified by code inspection on that branch, except where PR 1's implementation
8-
notes (end of §4) correct them. Goal: remove the last *runtime-patched* vtable
9-
path for the common case, make slot numbering single-sourced, and fix one
10-
latent cast-order miscompile found during review.
3+
Status: **PR 1 (§4 + §5) and PR 2 (§3) both implemented, full suite green
4+
(718/718)**. Follow-up to PR #251 (heap-allocated patched vtable) and PR #252
5+
(canonical slot numbering, `fix-interface-vtable-index-mismatch@be2c9620`).
6+
All anchors below verified by code inspection at the time each PR started;
7+
see each section's "Implementation notes" subsection for what changed, or was
8+
found to be wrong, while building it. Goal: remove the last *runtime-patched*
9+
vtable path for the common case, make slot numbering single-sourced, and fix
10+
latent bugs found during review and implementation.
1111

1212
## 1. Current architecture: what a vtable slot means
1313

@@ -120,6 +120,78 @@ the #251 heap machinery becomes dead on the main path (kept for the fallback);
120120
the vtable global can be const-qualified; cross-module behavior stops depending
121121
on which module happened to run a cast.
122122

123+
### Implementation notes (PR 2, as landed)
124+
125+
Two corrections to the plan above, both found by testing rather than
126+
inspection - the "captures need a fallback" premise was actually wrong in the
127+
*helpful* direction (one fewer case to handle), and a second, unrelated
128+
distinction turned out to be load-bearing that the plan didn't mention at all.
129+
130+
**Captures don't need the fallback.** Rereading `addObjectFuncFieldInfo`
131+
(`MLIRGenImpl.h`) shows the func-typed field's value is
132+
`FlatSymbolRefAttr(funcName)` unconditionally - captures-with-methods land in
133+
`methodInfosWithCaptures` *in addition to*, not *instead of*, the same
134+
symbol-valued field. What captures actually add is a separate `.captured`
135+
data field the (single, shared) lifted function reads via `this` at call time
136+
(`mlirGenObjectLiteralCaptures`) - not a different function per instance. So
137+
the side table (`objectLiteralMethodSymbolsMap`, keyed on the object literal's
138+
own `ObjectStorageType`, populated in `addObjectFuncFieldInfo` for every
139+
method, captures or not) needed no captures-awareness at all. The imported-
140+
object-type fallback (side-table miss, since there's no local `funcOp` to
141+
name) is the only case actually exercised in practice today - no test yet
142+
covers it explicitly (see §7's test matrix, still open).
143+
144+
**A vtable-identity subtlety this plan didn't anticipate, but had to solve
145+
to key the side table at all:** vtable globals are named by
146+
`hash_value(objectType)` (`interfaceVTableNameForObject`) - structural, not
147+
nominal. The worry this raises - two differently-implemented, same-shape
148+
literals colliding on one vtable global and a baked-in constant only being
149+
correct for one of them - does not happen, verified by compiling a
150+
counterexample (`{count:0,inc(){...+1}}` vs `{count:0,inc(){...+100}}`):
151+
they get different vtable globals. Why: a method field's `FunctionType`
152+
carries `this` as an explicit first parameter typed `object<object_storage<@literal's-own-symbol>>`,
153+
and that symbol embeds the literal's own source location - so the "same
154+
shape" premise never actually holds once the method's own signature is
155+
counted. This is also why the side table has to be keyed on that *nested*
156+
`ObjectStorageType` (recovered at the vtable-build site by reading
157+
`FunctionType.getInputs().front()` off the object's own field, cast to
158+
`ObjectType`, `.getStorageType()`), not on the boxed `ObjectType` directly,
159+
since the boxed type wraps a converted plain `TupleType` (`mlirGen(ObjectLiteralExpression)`,
160+
`convertConstTupleTypeToTupleType`), a *different* MLIR `Type` value than
161+
`oli.objThis` captured when the field was added, even though one is derived
162+
from the other.
163+
164+
**The bug that actually broke the regression suite (22 failures on the first
165+
pass): `PropertySignature` with a function type is not a `MethodSignature`.**
166+
`interface Something { toString: () => string; }` categorizes `toString` as
167+
an interface **field** (`InterfaceInfo::fields`), not a method
168+
(`InterfaceInfo::methods`) - `getVirtualTable`'s `methodsAsFields=true` only
169+
governs how the *object's own* func-typed field gets resolved into the local
170+
`virtualTable` snapshot; it collapses every entry to `isField=true`
171+
regardless of which list the *interface* actually declared the member in. The
172+
access site, however, dispatches on the interface's real categorization
173+
(`InterfaceMembers` tries `findField` before `findMethod`) - so
174+
`toString`'s access always goes through `InterfaceFieldAccess`
175+
(`thisVal + slotValue`, offset semantics), never `InterfaceMethodAccess`
176+
(raw function-pointer slot, `BoundFunctionType`), no matter how the object
177+
stores it. The first version of this change substituted a constant
178+
`SymbolRefOp` into *any* func-typed slot, including `toString`'s - handing a
179+
raw function pointer to code that adds it to `thisVal` expecting an offset,
180+
producing a garbage call target. Crash confirmed via WinDbg
181+
(`WinDbgX.exe -g -c ".dump /ma ..."` then offline `!analyze -v`; see
182+
[[windbg-available-for-debugging]]): faulting instruction `call qword ptr
183+
[rax+rdx]`, both registers holding `thisVal`/slot-sum garbage. Fix: gate the
184+
symbol substitution on `newInterfacePtr->findMethod(fieldName)` actually
185+
finding the member in the interface's own (extends-inclusive) method list,
186+
not merely on the vtable entry being func-typed
187+
(`mlirGenObjectVirtualTableDefinitionForInterface`). Regression test:
188+
`00interface_function_typed_field.ts` - `00interface_object.ts` (pre-existing)
189+
already covered this pattern and is what surfaced the bug, but a minimal,
190+
purpose-labeled test makes the invariant explicit for future readers.
191+
192+
718/718 tests green (716 from PR 1 + this section's new regression test +1
193+
already counted, plus the incidental fix above).
194+
123195
## 4. Change 2: a single writer for `virtualIndex`
124196

125197
Slot numbering is currently embodied in four places:

tslang/lib/TypeScript/MLIRGenImpl.h

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7423,6 +7423,21 @@ class MLIRGenImpl
74237423
oli.values.push_back(mlir::FlatSymbolRefAttr::get(builder.getContext(), funcName));
74247424
oli.fieldInfos.push_back({fieldId, type, false, mlir_ts::AccessLevel::Public});
74257425

7426+
// record (this literal's object-storage type, field name) -> the lifted function's
7427+
// symbol so a later interface vtable build (mlirGenObjectVirtualTableDefinitionForInterface)
7428+
// can emit a constant SymbolRefOp for this method slot instead of the runtime
7429+
// load-from-object patch - see docs/interface-vtable-simplification-design.md §3.
7430+
// oli.objThis.getStorageType() is the SAME (hash-consed) ObjectStorageType that ends
7431+
// up embedded as the "this" parameter of this field's own FunctionType, which is how
7432+
// the vtable builder recovers this key later even though by then it only has the
7433+
// BOXED object type (whose top-level storage is a plain TupleType, not this
7434+
// ObjectStorageType directly - see the design doc's §3 implementation notes for why
7435+
// that indirection is necessary, not optional). Captures are fine here too: a
7436+
// captures-bearing method's field value is still this same compile-time-constant
7437+
// funcName (the per-instance data lives in a separate `.captured` field the function
7438+
// reads via `this`, not in a different function per instance).
7439+
objectLiteralMethodSymbolsMap[oli.objThis.getStorageType()][fieldId] = funcName;
7440+
74267441
if (getCaptureVarsMap().find(funcName) != getCaptureVarsMap().end())
74277442
{
74287443
oli.methodInfosWithCaptures.push_back({funcName, oli.fieldInfos.size() - 1});
@@ -10852,6 +10867,43 @@ class MLIRGenImpl
1085210867

1085310868
llvm::ScopedHashTable<StringRef, InterfaceInfo::TypePtr> fullNameInterfacesMap;
1085410869

10870+
// (object literal's ObjectStorageType -> (field name -> lifted function symbol)) for
10871+
// capture-free-or-not object-literal methods; see addObjectFuncFieldInfo's doc comment
10872+
// and docs/interface-vtable-simplification-design.md §3. Not namespace-scoped: an
10873+
// ObjectStorageType's symbol already embeds the literal's source location, so it's
10874+
// globally unique regardless of which namespace declared it.
10875+
mlir::DenseMap<mlir::Type, mlir::DenseMap<mlir::Attribute, std::string>> objectLiteralMethodSymbolsMap;
10876+
10877+
// fieldType is a method-as-field's FunctionType, whose first input is the object literal's
10878+
// own "this" type (ObjectType wrapping the ObjectStorageType key used in
10879+
// objectLiteralMethodSymbolsMap - see addObjectFuncFieldInfo). Returns the empty string if
10880+
// fieldType isn't a function, "this" isn't an ObjectType (e.g. a class instance, or an
10881+
// imported object type reconstructed from a @dllimport declaration with no local funcOp),
10882+
// or no method was ever registered for this exact (object, field) pair.
10883+
std::string lookupObjectLiteralMethodSymbol(mlir::Type fieldType, mlir::Attribute fieldId)
10884+
{
10885+
auto funcType = dyn_cast<mlir_ts::FunctionType>(fieldType);
10886+
if (!funcType || funcType.getInputs().empty())
10887+
{
10888+
return {};
10889+
}
10890+
10891+
auto thisObjectType = dyn_cast<mlir_ts::ObjectType>(funcType.getInputs().front());
10892+
if (!thisObjectType)
10893+
{
10894+
return {};
10895+
}
10896+
10897+
auto symbolsForObject = objectLiteralMethodSymbolsMap.find(thisObjectType.getStorageType());
10898+
if (symbolsForObject == objectLiteralMethodSymbolsMap.end())
10899+
{
10900+
return {};
10901+
}
10902+
10903+
auto symbolIt = symbolsForObject->second.find(fieldId);
10904+
return symbolIt == symbolsForObject->second.end() ? std::string{} : symbolIt->second;
10905+
}
10906+
1085510907
llvm::ScopedHashTable<StringRef, GenericInterfaceInfo::TypePtr> fullNameGenericInterfacesMap;
1085610908

1085710909
llvm::ScopedHashTable<StringRef, VariableDeclarationDOM::TypePtr> fullNameGlobalsMap;

tslang/lib/TypeScript/MLIRGenInterfaces.cpp

Lines changed: 86 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,37 @@ namespace mlirgen
212212
return mlir::failure();
213213
}
214214

215+
// methods this literal's own funcName is known for were already baked into
216+
// globalVTableRefValue as constant SymbolRefOps by
217+
// mlirGenObjectVirtualTableDefinitionForInterface (see there, and
218+
// docs/interface-vtable-simplification-design.md §3) - only the rest (an
219+
// imported object type reconstructed from a @dllimport declaration, with no
220+
// local funcOp to name) still need their function pointer read out of the
221+
// actual object `in` at cast time.
222+
llvm::SmallVector<InterfaceMethodInfo *> methodsNeedingPatch;
223+
for (auto& method : newInterfacePtr->methods)
224+
{
225+
auto fieldId = builder.getStringAttr(method.name);
226+
auto index = mth.getFieldIndexByFieldName(storeType, fieldId);
227+
if (index == -1)
228+
{
229+
return mlir::failure();
230+
}
231+
232+
auto fieldInfo = mth.getFieldInfoByIndex(storeType, index);
233+
if (lookupObjectLiteralMethodSymbol(fieldInfo.type, fieldId).empty())
234+
{
235+
methodsNeedingPatch.push_back(&method);
236+
}
237+
}
238+
239+
if (methodsNeedingPatch.empty())
240+
{
241+
// every method slot is already a compile-time constant - no per-cast
242+
// heap allocation needed, same footing as a method-less interface.
243+
return globalVTableRefValue;
244+
}
245+
215246
// match VTable
216247
// 1) clone vtable onto the GC heap (NOT a stack VariableOp): this per-object
217248
// patched vtable is pointed at by the resulting interface value, and that
@@ -226,8 +257,9 @@ namespace mlirgen
226257
builder.create<mlir_ts::StoreOp>(location, valueVTable, heapVTable);
227258
auto varVTable = builder.create<mlir_ts::CastOp>(location, globalVTableRefValue.getType(), heapVTable);
228259

229-
for (auto& method : newInterfacePtr->methods)
260+
for (auto* methodPtr : methodsNeedingPatch)
230261
{
262+
auto& method = *methodPtr;
231263
auto index = mth.getFieldIndexByFieldName(storeType, builder.getStringAttr(method.name));
232264
if (index == -1)
233265
{
@@ -246,13 +278,13 @@ namespace mlirgen
246278
auto methodRefVT = builder.create<mlir_ts::PropertyRefOp>(location, fieldInfoVT.type, varVTable, method.virtualIndex);
247279

248280
LLVM_DEBUG(llvm::dbgs() << "\n!!\n\t vtable method: " << method.name
249-
<< "\n\t vtable method ref: " << V(methodRefVT) << "\n\n";);
250-
251-
builder.create<mlir_ts::LoadSaveOp>(location, methodRefVT, methodRef);
252-
}
281+
<< "\n\t vtable method ref: " << V(methodRefVT) << "\n\n";);
282+
283+
builder.create<mlir_ts::LoadSaveOp>(location, methodRefVT, methodRef);
284+
}
253285

254286
// patched VTable
255-
return V(varVTable);
287+
return V(varVTable);
256288
}
257289

258290
return globalVTableRefValue;
@@ -301,9 +333,56 @@ namespace mlirgen
301333
{
302334
if (methodOrField.isField)
303335
{
304-
auto nullObj = builder.create<mlir_ts::NullOp>(location, getNullType());
336+
// an object-literal method is resolved via the FIELD path too
337+
// (methodsAsFields=true, getInterfaceVirtualTableForObject) since it's
338+
// literally stored as a func-typed field on the object - but if it's
339+
// capture-free-or-not-relevant (its funcName is compile-time constant
340+
// regardless of captures, see addObjectFuncFieldInfo), we already know
341+
// exactly which function it is and don't need to derive an object-relative
342+
// offset for it at all: emit the function symbol directly, same as the
343+
// class vtable path (mlirGenClassVirtualTableDefinitionForInterface) does
344+
// for its own (isField=false) methods. This makes the slot a genuine
345+
// compile-time constant, skipping mlirGenCreateInterfaceVTableForObject's
346+
// per-cast heap-clone-and-patch entirely for such methods (see there).
347+
// See docs/interface-vtable-simplification-design.md §3.
348+
//
349+
// Gate on the INTERFACE's own categorization, not merely "is this
350+
// vtable entry func-typed": `methodsAsFields=true` makes every entry
351+
// here isField=true regardless of whether the interface declared it as
352+
// a MethodSignature (`inc(): void`) or a PropertySignature with a
353+
// function type (`toString: () => string`) - only the former is read
354+
// through InterfaceMethodAccess (BoundFunctionType access, raw funcptr
355+
// slot semantics) at the access site; the latter goes through
356+
// InterfaceFieldAccess, which computes thisVal + slotValue expecting an
357+
// OFFSET. Substituting a raw function pointer into a
358+
// PropertySignature-with-function-type's slot corrupts that
359+
// computation (crashes on the resulting garbage address).
360+
std::string methodFuncName;
305361
if (!methodOrField.isMissing)
306362
{
363+
auto fieldNameAttr = dyn_cast<mlir::StringAttr>(methodOrField.fieldInfo.id);
364+
if (fieldNameAttr && newInterfacePtr->findMethod(fieldNameAttr.getValue()))
365+
{
366+
methodFuncName = lookupObjectLiteralMethodSymbol(methodOrField.fieldInfo.type, methodOrField.fieldInfo.id);
367+
}
368+
}
369+
370+
if (!methodFuncName.empty())
371+
{
372+
auto methodConstName = builder.create<mlir_ts::SymbolRefOp>(
373+
location, mlir::cast<mlir_ts::FunctionType>(methodOrField.fieldInfo.type),
374+
mlir::FlatSymbolRefAttr::get(builder.getContext(), methodFuncName));
375+
auto methodConstNameRef = cast(location, mlir_ts::RefType::get(methodOrField.fieldInfo.type),
376+
methodConstName, genContext);
377+
378+
vtableValue = builder.create<mlir_ts::InsertPropertyOp>(
379+
location, virtTuple, methodConstNameRef, vtableValue,
380+
MLIRHelper::getStructIndex(builder, fieldIndex));
381+
}
382+
else if (!methodOrField.isMissing)
383+
{
384+
auto nullObj = builder.create<mlir_ts::NullOp>(location, getNullType());
385+
307386
// TODO: test cast result
308387
auto objectNull = cast(location, objectType, nullObj, genContext, true);
309388
if (!objectNull)

tslang/test/tester/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,7 @@ add_test(NAME test-compile-00-interface-conjunction COMMAND test-runner "${PROJE
285285
add_test(NAME test-compile-00-interface-partial COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_partial.ts")
286286
add_test(NAME test-compile-00-interface-optional COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_optional.ts")
287287
add_test(NAME test-compile-00-interface-optional-cast-order COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_optional_cast_order.ts")
288+
add_test(NAME test-compile-00-interface-function-typed-field COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_function_typed_field.ts")
288289
add_test(NAME test-compile-00-interface-generic COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_generic.ts")
289290
add_test(NAME test-compile-00-interface-new COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_new.ts")
290291
add_test(NAME test-compile-00-interface-indexer COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_indexer.ts")
@@ -636,6 +637,7 @@ add_test(NAME test-jit-00-interface-conjunction COMMAND test-runner -jit "${PROJ
636637
add_test(NAME test-jit-00-interface-partial COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_partial.ts")
637638
add_test(NAME test-jit-00-interface-optional COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_optional.ts")
638639
add_test(NAME test-jit-00-interface-optional-cast-order COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_optional_cast_order.ts")
640+
add_test(NAME test-jit-00-interface-function-typed-field COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_function_typed_field.ts")
639641
add_test(NAME test-jit-00-interface-generic COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_generic.ts")
640642
add_test(NAME test-jit-00-interface-new COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_new.ts")
641643
add_test(NAME test-jit-00-interface-indexer COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00interface_indexer.ts")

0 commit comments

Comments
 (0)