Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 20 additions & 11 deletions tslang/benchmarks/raytrace/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ pwsh tslang/benchmarks/raytrace/bench.ps1
- Rust: `opt-level=3`, `lto="fat"`, `codegen-units=1`, `panic="abort"`
- C++: `clang++ -std=c++20 -O3`, and `cl /std:c++20 /O2 /EHsc /MT`

## Results 2026-10-31: parameters captured by copy, direct method calls
## Results 2026-10-31: parameters captured by copy, direct method calls, null without a tag

Release, Windows 11 x64, the same machine; main 1cd17c33 against the same tree with each change
added in turn. Median of 7 runs after a warm-up; every build prints the same checksum. Neither
Expand All @@ -44,16 +44,25 @@ benchmark file changed.
inline it. A class that code outside the module may extend (exported, declared or imported, a
C++ binding, or a base of one of these) keeps its methods virtual. That takes in `RayTracer`'s
methods: variant F below, now without the source edit.

| Build | main | parameters by copy | and direct method calls | vs Rust idiomatic (55 ms) |
|---|---|---|---|---|
| tslang gc | 266 ms | 97 ms | 79 ms | 1.4x |
| tslang rc | 171 ms | 133 ms | 120 ms | 2.2x |
| tslang none | 157 ms | 96 ms | 79 ms | 1.4x |
| tslang own | 143 ms | 104 ms | 86 ms | 1.6x |

Of the three costs measured below, A's cells and F's virtual calls are now gone without editing
the source; B, `Intersection | null`, measured 16-17 ms under none and own, and remains.
- Null tested by kind: a union's tag points into its type's descriptor, so whether it holds null
is the descriptor's kind (`TYPE_KIND_NULL`), one load and one compare. It was a `strcmp` of the
type's name with `"null"`, on every `intersect` result and every `traceRay` and `testRay`.
- Null in the reference: `Intersection` holds an interface, `thing`, and an interface is null when
the object in it is (`{vtable, this}`, `this` null). So `Intersection | null` needs no tag: it is
stored as an `Intersection`, null being one whose `thing` holds no object, and the 88-byte
tagged result of `intersect` is the value itself with the optional's flag. A `@value` type with
only numbers keeps its tag.

| Build | main | parameters by copy | and direct method calls | and null by kind | and null in the reference | vs Rust idiomatic (55 ms) |
|---|---|---|---|---|---|---|
| tslang gc | 266 ms | 97 ms | 79 ms | 75 ms | 71 ms | 1.3x |
| tslang rc | 171 ms | 133 ms | 120 ms | 115 ms | 110 ms | 2.0x |
| tslang none | 157 ms | 96 ms | 79 ms | 74 ms | 70 ms | 1.3x |
| tslang own | 143 ms | 104 ms | 86 ms | 82 ms | 77 ms | 1.4x |

Of the three costs measured below, A's cells, F's virtual calls and B's union are now gone without
editing the source: variant B (`intersect` returns `dist: 0` for a miss, no union) runs in 69.9 ms
under none, as the benchmark itself now does.

## Results 2026-10-24: `@value`

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,14 @@ TypeScript lets `null` and `undefined` stand for an `Intersection`. A value has
`object` tag and the tuple. `=== undefined` reads the `i1`; `=== null` and `== null` read it and
then compare the tag with the string `"null"` (`strcmp` when the tag is not a null pointer).
This is the cost the benchmark README measures for `Intersection | null`.
- 2026-10-31: the `null` test reads the tag's descriptor kind (`TYPE_KIND_NULL`) instead of the
`strcmp`. And a `T` that holds an interface or a class (`Intersection.thing`), directly or in a
`@value` field, holds null in it: an interface is null when its `this` is, a class when its
pointer is (`MLIRTypeHelper::getValueNullNiche`, the first such field). Its union has no tag
(`isUnionTypeNeedsTag` answers no, base type `T`): null is `T` zeroed, `== null` reads that
pointer, and `T | null` is passed as `{ T, i1 }`. Undefined is still the empty optional, so null
and undefined stay apart. Under `// @strict-null false` a `T` built with that field null
(`{ thing: null, ... }`) therefore reads as null.
- Strict comparisons with `null` and `undefined` are exact, as in TypeScript: `x === undefined` is
false for `null`, and `x === null` false for `undefined`. Loose `x == null` and `x != null` ask
whether `x` holds a value. Comparing one with anything else (another value, a number) is the
Expand Down
15 changes: 15 additions & 0 deletions tslang/include/TypeScript/LowerToLLVM/CastLogicHelper.h
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,13 @@ class CastLogicHelper
}
else
{
// null into `T | null` of a @value type that holds null in a pointer of its own
// (MLIRTypeHelper::getValueNullNiche): T with every field zeroed, that pointer too
if (isa<mlir_ts::NullType>(inType) && isa<mlir_ts::ValueType>(baseType))
{
return rewriter.create<LLVM::ZeroOp>(loc, tch.convertType(baseType));
}

return cast(in, inType, tch.convertType(inType), baseType, tch.convertType(baseType));
}
}
Expand Down Expand Up @@ -1302,6 +1309,14 @@ class CastLogicHelper
auto nullTag = toh.typeOfLogic(loc, mlir_ts::NullType::get(rewriter.getContext()));
mlir::Value pointer =
in.getType() == inLLVMType ? in : rewriter.create<mlir_ts::DialectCastOp>(loc, inLLVMType, in);
// ...or a @value type that holds null in a pointer of its own (MLIRTypeHelper::
// getValueNullNiche): that pointer
if (auto valueType = dyn_cast<mlir_ts::ValueType>(baseType))
{
auto niche = MLIRTypeHelper::getValueNullNiche(valueType);
pointer = rewriter.create<LLVM::ExtractValueOp>(loc, pointer, llvm::SmallVector<int64_t>(niche.begin(), niche.end()));
}

auto isSet = rewriter.create<LLVM::ICmpOp>(loc, LLVM::ICmpPredicate::ne, pointer,
rewriter.create<LLVM::ZeroOp>(loc, th.getPtrType()));
auto tagType = setTag.getType();
Expand Down
55 changes: 52 additions & 3 deletions tslang/include/TypeScript/LowerToLLVM/UnaryBinLogicalOrHelper.h
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
#include "TypeScript/TypeScriptOps.h"

#include "TypeScript/LowerToLLVM/LLVMTypeConverterHelper.h"
#include "TypeScript/LowerToLLVM/TypeConverterHelper.h"
#include "TypeScript/LowerToLLVM/TypeDescriptorLogic.h"
#include "TypeScript/MLIRLogic/TypeOfOpHelper.h"
#include "TypeScript/LowerToLLVM/LLVMCodeHelperBase.h"

Expand Down Expand Up @@ -129,11 +131,45 @@ mlir::Value LogicOp(Operation *binOp, SyntaxKind op, mlir::Value left, mlir::Typ
return builder.create<StdIOpTy>(loc, v1, pointerWord, zero);
}

// The tag points into the type's descriptor (TypeOfOpHelper::typeDescriptorValue), whose kind
// says null in one load and one compare - not a strcmp of the name with "null", which every
// `Intersection | null` result paid for. A tag never set (a zeroed union) is not null, as it
// was not equal to "null": it reads the descriptor of undefined instead.
auto tagValue = builder.create<LLVM::ExtractValueOp>(loc, llvmtch.typeConverter->convertType(mth.getStringType()),
llvmUnion, MLIRHelper::getStructIndex(builder, UNION_TAG_INDEX));
auto nullStr = builder.create<mlir_ts::ConstantOp>(loc, mth.getStringType(), builder.getStringAttr("null"));
return builder.create<mlir_ts::StringCompareOp>(
loc, mth.getBooleanType(), tagValue, nullStr, builder.getI32IntegerAttr((int)op));
auto tagIsSet = builder.create<LLVM::ICmpOp>(loc, LLVM::ICmpPredicate::ne, tagValue,
builder.create<LLVM::ZeroOp>(loc, tagValue.getType()));
TypeOfOpHelper toh(builder);
auto undefinedTag = builder.create<mlir_ts::DialectCastOp>(
loc, tagValue.getType(), toh.typeDescriptorValue(loc, mlir_ts::UndefinedType::get(builder.getContext())));
auto readTag = builder.create<LLVM::SelectOp>(loc, tagIsSet, tagValue, undefinedTag);
TypeConverterHelper tch(&typeConverter);
TypeDescriptorLogic tdl(builder, tch, loc);
auto isNull = tdl.isKind(readTag, TYPE_KIND_NULL);
auto trueValue = builder.create<LLVM::ConstantOp>(loc, isNull.getType(), builder.getBoolAttr(true));
return builder.create<StdIOpTy>(loc, v1, isNull, trueValue);
};

// `T | null` of a @value type T that holds null in a pointer of its own (MLIRTypeHelper::
// getValueNullNiche) is stored as T: it is null when that pointer is
auto valueNullCompare = [&](mlir::Value unionValue, mlir_ts::UnionType unionType) -> mlir::Value {
auto valueType = ::typescript::MLIRTypeHelper::getMissingValueElement(unionType);
if (!valueType)
{
return mlir::Value();
}

auto niche = ::typescript::MLIRTypeHelper::getValueNullNiche(valueType);
if (niche.empty())
{
return mlir::Value();
}

auto llvmValue = asLLVMUnion(unionValue, unionType);
llvm::SmallVector<int64_t> path(niche.begin(), niche.end());
auto pointerWord = builder.create<LLVM::ExtractValueOp>(loc, llvmValue, path);
auto zero = builder.create<LLVM::ZeroOp>(loc, pointerWord.getType());
return builder.create<StdIOpTy>(loc, v1, pointerWord, zero).getResult();
};

if (isa<mlir_ts::OptionalType>(leftType) || isa<mlir_ts::OptionalType>(rightType))
Expand Down Expand Up @@ -248,6 +284,11 @@ mlir::Value LogicOp(Operation *binOp, SyntaxKind op, mlir::Value left, mlir::Typ
return nullCompare(right, unionType);
}

if (auto compared = valueNullCompare(right, unionType))
{
return compared;
}

// the union is the right operand, so it is the right type that becomes its base type:
// passing the base type as the left type and the union again as the right one came
// straight back here, until the compiler's stack ran out (`opaque != (opaque | null)`)
Expand Down Expand Up @@ -342,6 +383,14 @@ mlir::Value LogicOp(Operation *binOp, SyntaxKind op, mlir::Value left, mlir::Typ
return mlir::Value();
}

if (right.getDefiningOp<mlir_ts::NullOp>() || right.getDefiningOp<LLVM::ZeroOp>() || matchPattern(right, m_Zero()))
{
if (auto compared = valueNullCompare(left, unionType))
{
return compared;
}
}

return LogicOp<StdIOpTy, V1, v1, StdFOpTy, V2, v2>(binOp, op, left, baseType, right, rightType, builder, typeConverter, compileOptions);
}
else if (auto leftTupleType = dyn_cast<mlir_ts::TupleType>(leftType))
Expand Down
50 changes: 50 additions & 0 deletions tslang/include/TypeScript/MLIRLogic/MLIRTypeHelper.h
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,48 @@ class MLIRTypeHelper
return mlir_ts::OptionalType::get(mlir_ts::UnionType::get(valueType.getContext(), members));
}

// Where null of a @value type T is in T itself: the index path, into T's storage, of a pointer no
// value of T holds as null - the `this` of a field of an interface type (`{vtable, this}`, null
// exactly when the interface is), or a field of a class type - so `T | null` needs no tag: null is
// T with that pointer null (zeroed). Fields of a @value type T holds are looked into as well.
// Empty when T has no such field (only numbers, say), and its null keeps a tag of its own.
static llvm::SmallVector<int32_t> getValueNullNiche(mlir_ts::ValueType valueType)
{
llvm::SmallVector<int32_t> path;
auto storageType = dyn_cast<mlir_ts::TupleType>(valueType.getStorageType());
if (!storageType)
{
return path;
}

for (auto [index, field] : llvm::enumerate(storageType.getFields()))
{
auto fieldType = field.type;
if (isa<mlir_ts::InterfaceType>(fieldType))
{
return {static_cast<int32_t>(index), THIS_VALUE_INDEX};
}

if (isa<mlir_ts::ClassType>(fieldType))
{
return {static_cast<int32_t>(index)};
}

if (auto innerValueType = dyn_cast<mlir_ts::ValueType>(fieldType))
{
auto inner = getValueNullNiche(innerValueType);
if (!inner.empty())
{
path.push_back(static_cast<int32_t>(index));
path.append(inner.begin(), inner.end());
return path;
}
}
}

return path;
}

// `T | null` of a @value type T, which getMissingValueType wraps in the optional
static bool isMissingValueUnion(mlir_ts::UnionType unionType)
{
Expand Down Expand Up @@ -2447,6 +2489,14 @@ class MLIRTypeHelper
// merged again would be the optional around it, not it
if (isMissingValueUnion(unionType))
{
// ...unless T holds null in a pointer of its own (getValueNullNiche): then it is T
auto valueType = getMissingValueElement(unionType);
if (valueType && !getValueNullNiche(valueType).empty())
{
baseType = valueType;
return false;
}

baseType = unionType;
return true;
}
Expand Down
21 changes: 21 additions & 0 deletions tslang/include/TypeScript/MLIRLogic/TypeOfOpHelper.h
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,27 @@ class TypeOfOpHelper
return rewriter.create<mlir_ts::GetTypeInfoFromUnionOp>(loc, mlir_ts::StringType::get(rewriter.getContext()), value);
}

// `T | null` of a @value type that holds null in a pointer of its own (MLIRTypeHelper::
// getValueNullNiche) is T with no tag: null is that pointer null, so asked here
if (isa<mlir_ts::ValueType>(baseType))
{
auto stringType = mlir_ts::StringType::get(rewriter.getContext());
auto nullValue = rewriter.create<mlir_ts::NullOp>(loc, mlir_ts::NullType::get(rewriter.getContext()));
auto isNull = rewriter.create<mlir_ts::LogicalBinaryOp>(
loc, mlir_ts::BooleanType::get(rewriter.getContext()),
rewriter.getI32IntegerAttr((int)SyntaxKind::EqualsEqualsEqualsToken), value, nullValue);
auto ifOp = rewriter.create<mlir_ts::IfOp>(loc, stringType, isNull, true);

rewriter.setInsertionPointToStart(&ifOp.getThenRegion().back());
rewriter.create<mlir_ts::ResultOp>(loc, typeDescriptorValue(loc, mlir_ts::NullType::get(rewriter.getContext())));

rewriter.setInsertionPointToStart(&ifOp.getElseRegion().back());
rewriter.create<mlir_ts::ResultOp>(loc, typeOfLogic(loc, baseType));

rewriter.setInsertionPointAfter(ifOp);
return ifOp.getResult(0);
}

origType = baseType;
}

Expand Down
12 changes: 11 additions & 1 deletion tslang/lib/TypeScript/OwnershipFacts.h
Original file line number Diff line number Diff line change
Expand Up @@ -393,8 +393,18 @@ inline bool isView(mlir::Operation *op)
return valueType && valueType.getStorageType() == tuple;
};

// ...or a `@value` value seen as its own `T | null`, or read back from it: when T holds null in a
// pointer of its own (MLIRTypeHelper::getValueNullNiche) the union is T itself, no tag added, and
// the cast stays a cast where a tagged one becomes CreateUnionInstanceOp
auto isMissingValueView = [](mlir::Type value, mlir::Type missing) {
auto valueType = mlir::dyn_cast<mlir_ts::ValueType>(value);
return valueType && mlir::isa<mlir_ts::UnionType>(missing) &&
MLIRTypeHelper::getMissingValueElement(missing) == valueType;
};

return (keeps(in) && keeps(out)) || (isClosure(in) && isClosure(out)) || isMadeObject() || isNullableView ||
isValueStorage(in, out) || isValueStorage(out, in);
isValueStorage(in, out) || isValueStorage(out, in) || isMissingValueView(in, out) ||
isMissingValueView(out, in);
}

// The block a value is a view of.
Expand Down
7 changes: 7 additions & 0 deletions tslang/test/tester/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3385,6 +3385,13 @@ foreach(value_mm gc rc none own)
tslang_add_test(NAME test-compile-value-missing-${value_mm} COMMAND test-runner -mm=${value_mm} "${PROJECT_SOURCE_DIR}/test/tester/value/value_missing.ts")
tslang_add_test(NAME test-jit-value-missing-${value_mm} COMMAND test-runner -jit -mm=${value_mm} "${PROJECT_SOURCE_DIR}/test/tester/value/value_missing.ts")
endforeach()
# ...the same of a @value type holding an interface or a class, whose `T | null` has no tag: null is
# that reference null (MLIRTypeHelper::getValueNullNiche). Not under own, which refuses the program's
# records holding a borrowed global (`thing: ball`) in fields and loops; own_raytrace covers own.
foreach(value_mm gc rc none)
tslang_add_test(NAME test-compile-value-missing-niche-${value_mm} COMMAND test-runner -mm=${value_mm} "${PROJECT_SOURCE_DIR}/test/tester/value/value_missing_niche.ts")
tslang_add_test(NAME test-jit-value-missing-niche-${value_mm} COMMAND test-runner -jit -mm=${value_mm} "${PROJECT_SOURCE_DIR}/test/tester/value/value_missing_niche.ts")
endforeach()
# ...and `g?.ray.dir.x` (and `.len()`) of a missing one skips the rest of the chain: undefined, as is a
# type literal's
foreach(value_mm gc rc none own)
Expand Down
Loading
Loading