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
41 changes: 41 additions & 0 deletions tslang/benchmarks/raytrace/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,47 @@ 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-24

Release, Windows 11 x64, the same machine and toolchains as below; median of 7 runs after a
warm-up, `bench.ps1`. Every build prints the same checksum. "main" is 63712f3b; "allocator as
malloc" adds the change that declares the process-heap allocator to LLVM as LLVM declares `malloc`
(ProcessHeapPass): its result is a block no other pointer reaches (`noalias`), and it touches only
the allocator's own state.

| Build | main | allocator as malloc | vs Rust idiomatic |
|---|---|---|---|
| tslang gc | 1189 ms | 1199 ms | 22x |
| tslang rc | 606 ms | 630 ms | 11.6x |
| tslang none | 642 ms | 434 ms | 8.0x |
| tslang own | 597 ms | 444 ms | 8.2x |
| Rust idiomatic | 54 ms | | 1x |
| Rust Rc-boxed | 490 ms | | 9.0x |
| C++ idiomatic (clang) | 50 ms | | 0.9x |
| C++ idiomatic (MSVC) | 68 ms | | 1.2x |
| C++ shared_ptr (clang) | 829 ms | | 15x |
| C++ shared_ptr (MSVC) | 896 ms | | 16x |

`--no-stack-promotion` changes rc and own by 2% or less in either column.

Where the time goes, from the optimized LLVM IR of `RayTracer.shade` and its neighbours:

- LLVM inlines `Vector.plus`, `Color.scale` and the rest, but each inlined copy still allocates.
Before this change the allocator was declared as writing other memory, so every `new` clobbered
the fields just stored into the previous object: they were loaded back from the heap instead of
reused, and the object could not be deleted. With the `malloc` declaration, own and none drop
about a quarter of their time. gc uses its own allocator (GCPass) and is unchanged; rc is 4%
slower, which this run does not explain.
- Under own, a release first reads the block's header to skip immortal objects (globals such as
`Color.black`). On an object the function just made, that read can only see a mortal header, but
LLVM does not prove it here, and the one load keeps the block alive. Removing it by hand from the
optimized IR (a simulation, not a compiler change) takes own to about 295 ms.
- What is left escapes in ways no local rewrite removes: vectors passed to calls through the `Thing`
interface (`isect.thing.normal(pos)`), the surface functions and the virtual `RayTracer` methods;
the `Ray` and `Intersection` literals, boxed as interfaces; and the `addLight` closure. Those need
objects that live on the caller's stack across a call, or values that are not heap objects at
all.

## Results 2026-10-21 (own rows 2026-10-23)

main 49b5f6bb, Release, Windows 11 x64; Rust 1.99.0, clang 21.1.4, MSVC from Visual Studio 18;
Expand Down
18 changes: 14 additions & 4 deletions tslang/lib/TypeScript/ProcessHeapPass.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -138,18 +138,28 @@ class ProcessHeapPass : public mlir::PassWrapper<ProcessHeapPass, ModulePass>

auto *context = funcOp->getContext();

// an allocation writes only the block it makes (other memory, as GCPass says it of
// GC_malloc) and the allocator's state; a release or a resize reads and writes the block it
// is given as well
// an allocation touches only the allocator's state, as LLVM declares malloc
// (`memory(inaccessiblemem: readwrite)`): the block it returns is new, not memory anyone
// could read before. Said to write other memory, it clobbered every value the program had
// just stored, so a field written into a fresh object was loaded back after the next
// allocation instead of reused, and the object could never be deleted. Writing the
// allocator's state is also what keeps two equal allocations from merging (GCPass). A
// release or a resize reads and writes the block it is given as well.
auto memoryEffects =
takesBlock ? LLVM::MemoryEffectsAttr::get(context, LLVM::ModRefInfo::NoModRef, LLVM::ModRefInfo::ModRef,
LLVM::ModRefInfo::ModRef, LLVM::ModRefInfo::NoModRef,
LLVM::ModRefInfo::NoModRef, LLVM::ModRefInfo::NoModRef)
: LLVM::MemoryEffectsAttr::get(context, LLVM::ModRefInfo::Mod, LLVM::ModRefInfo::NoModRef,
: LLVM::MemoryEffectsAttr::get(context, LLVM::ModRefInfo::NoModRef, LLVM::ModRefInfo::NoModRef,
LLVM::ModRefInfo::ModRef, LLVM::ModRefInfo::NoModRef,
LLVM::ModRefInfo::NoModRef, LLVM::ModRefInfo::NoModRef);
funcOp.setMemoryEffectsAttr(memoryEffects);

// and no other pointer reaches the block, as `noalias` says of malloc's result
if (!takesBlock && funcOp.getNumResults() > 0)
{
funcOp.setResultAttr(0, LLVM::LLVMDialect::getNoAliasAttrName(), mlir::UnitAttr::get(context));
}

if (takesBlock && funcOp.getNumArguments() > 0)
{
funcOp.setArgAttr(0, LLVM::LLVMDialect::getAllocatedPointerAttrName(), mlir::UnitAttr::get(context));
Expand Down
10 changes: 10 additions & 0 deletions tslang/test/tester/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,7 @@ tslang_add_test(NAME test-compile-00-conditional-named-const-release COMMAND tes
tslang_add_test(NAME test-compile-00-array-length-valid COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00array_length_valid.ts")
tslang_add_test(NAME test-compile-00-const-interface-of-local COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00const_interface_of_local.ts")
tslang_add_test(NAME test-compile-00-self-assign COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00self_assign.ts")
tslang_add_test(NAME test-compile-00-fresh-objects-folded COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00fresh_objects_folded.ts")
tslang_add_test(NAME test-compile-00-string-view-copy COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00string_view_copy.ts")
tslang_add_test(NAME test-compile-00-global-copy-counted COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00global_copy_counted.ts")
tslang_add_test(NAME test-compile-00-global-interface-initializer COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00global_interface_initializer.ts")
Expand Down Expand Up @@ -999,6 +1000,7 @@ tslang_add_test(NAME test-jit-00-conditional-named-const-release COMMAND test-ru
tslang_add_test(NAME test-jit-00-array-length-valid COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00array_length_valid.ts")
tslang_add_test(NAME test-jit-00-const-interface-of-local COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00const_interface_of_local.ts")
tslang_add_test(NAME test-jit-00-self-assign COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00self_assign.ts")
tslang_add_test(NAME test-jit-00-fresh-objects-folded COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00fresh_objects_folded.ts")
tslang_add_test(NAME test-jit-00-string-view-copy COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00string_view_copy.ts")
tslang_add_test(NAME test-jit-00-global-copy-counted COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00global_copy_counted.ts")
tslang_add_test(NAME test-jit-00-global-interface-initializer COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00global_interface_initializer.ts")
Expand Down Expand Up @@ -3901,6 +3903,14 @@ add_test(NAME test-rc-conditional-named-const-release
"-DFILE=${PROJECT_SOURCE_DIR}/test/tester/tests/00conditional_named_const_release.ts"
-P "${CMAKE_CURRENT_SOURCE_DIR}/conditional-named-const-release.cmake")

# A fresh object's field read after another allocation is the value just stored: the optimized IR
# loads nothing back, under none, rc and own (the allocator is declared as LLVM declares malloc).
add_test(NAME test-fresh-objects-folded
COMMAND ${CMAKE_COMMAND} -DTSLANG=$<TARGET_FILE:tslang>
"-DFILE=${PROJECT_SOURCE_DIR}/test/tester/tests/00fresh_objects_folded.ts"
"-DWORK_DIR=${CMAKE_CURRENT_BINARY_DIR}"
-P "${CMAKE_CURRENT_SOURCE_DIR}/fresh-objects-folded.cmake")

# A generator body's temporaries are released before the yield that ends the call, where no resume
# point comes between, and the file runs under own as well.
add_test(NAME test-rc-generator-temporaries
Expand Down
24 changes: 24 additions & 0 deletions tslang/test/tester/fresh-objects-folded.cmake
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# A fresh object's field read after another allocation is the value just stored
# (00fresh_objects_folded.ts): the optimized LLVM IR of `keep` loads no double, under every model
# that frees. The allocator helpers are declared as LLVM declares malloc (ProcessHeapPass); declared
# as writing other memory, the second allocation clobbered the first object's field.
cmake_minimum_required(VERSION 3.17.3)
foreach(mm none rc own)
set(ll "${WORK_DIR}/fresh_objects_folded_${mm}.ll")
execute_process(COMMAND "${TSLANG}" --emit=llvm --opt --opt_level=3 --no-default-lib -mm=${mm} "${FILE}" -o "${ll}"
OUTPUT_VARIABLE out ERROR_VARIABLE err RESULT_VARIABLE result)
if(NOT result EQUAL 0)
message(FATAL_ERROR "-mm=${mm}: tslang failed (${result}):\n${out}${err}")
endif()
file(READ "${ll}" ir)
string(FIND "${ir}" "@keep(" begin)
if(begin EQUAL -1)
message(FATAL_ERROR "-mm=${mm}: no function `keep` in:\n${ir}")
endif()
string(SUBSTRING "${ir}" ${begin} -1 body)
string(FIND "${body}" "\n}" end)
string(SUBSTRING "${body}" 0 ${end} body)
if(body MATCHES "load double")
message(FATAL_ERROR "-mm=${mm}: `keep` loads a field it just stored:\n${body}")
endif()
endforeach()
27 changes: 27 additions & 0 deletions tslang/test/tester/tests/00fresh_objects_folded.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// A field of a fresh object, read after another object is made: with optimization it is the value
// just stored, not a load. The allocator is declared to LLVM as malloc is: its result is a block no
// other pointer reaches, and it writes nothing the program can read. Declared as writing other
// memory, the second `new` clobbered `v.x`, which was loaded back from the heap; in the ray tracer
// that kept every vector of an expression like `Vector.plus(Vector.times(k, d), p)` alive.
// fresh-objects-folded.cmake reads the optimized IR of `keep`; here the program runs.
class V {
constructor(public x: number) {}
static of(x: number) {
return new V(x);
}
}

export function keep(a: number, b: number, sink: (v: V) => void) {
const v = V.of(a);
const w = V.of(b);
const r = v.x + w.x;
sink(v);
sink(w);
return r;
}

function main() {
let n = 0;
assert(keep(1, 2, (v: V) => { n += v.x; }) == 3 && n == 3, "the fields are the ones stored");
print("done.");
}
Loading