From ff19f31c1f8cd62e3c834af085e47221816a6595 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 24 Oct 2026 10:20:35 +0100 Subject: [PATCH] Declare the process-heap allocator to LLVM as LLVM declares malloc ProcessHeapPass marked __tslang_heap_malloc/calloc/aligned_alloc as writing other memory and gave their result no `noalias`. So every allocation clobbered whatever the program had just stored: a field written into a fresh object was loaded back from the heap after the next `new` instead of reused, and the object could never be deleted. In the ray tracer every vector of `Vector.plus(Vector.times(k, d), p)` stayed on the heap although LLVM had inlined the helpers. An allocation now touches only the allocator's state (`memory(inaccessiblemem: readwrite)`) and returns a `noalias` block, as LLVM's own malloc does. Writing the allocator's state still keeps two equal allocations from merging (the GC_malloc case in GCPass). Ray tracer, 512x512, median of 7: own 597 -> 444 ms, none 642 -> 434 ms, gc unchanged (its own allocator), rc 606 -> 630 ms. The benchmark README records the numbers and what is left between own and Rust. Test: 00fresh_objects_folded.ts runs, and fresh-objects-folded.cmake checks that the optimized IR of `keep` loads no field back under none, rc and own (it fails before this change). Co-Authored-By: Claude Opus 5.5 --- tslang/benchmarks/raytrace/README.md | 41 +++++++++++++++++++ tslang/lib/TypeScript/ProcessHeapPass.cpp | 18 ++++++-- tslang/test/tester/CMakeLists.txt | 10 +++++ tslang/test/tester/fresh-objects-folded.cmake | 24 +++++++++++ .../tester/tests/00fresh_objects_folded.ts | 27 ++++++++++++ 5 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 tslang/test/tester/fresh-objects-folded.cmake create mode 100644 tslang/test/tester/tests/00fresh_objects_folded.ts diff --git a/tslang/benchmarks/raytrace/README.md b/tslang/benchmarks/raytrace/README.md index 2d5699881..c07fd02bc 100644 --- a/tslang/benchmarks/raytrace/README.md +++ b/tslang/benchmarks/raytrace/README.md @@ -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; diff --git a/tslang/lib/TypeScript/ProcessHeapPass.cpp b/tslang/lib/TypeScript/ProcessHeapPass.cpp index 5bcfaf093..4afe4f5b9 100644 --- a/tslang/lib/TypeScript/ProcessHeapPass.cpp +++ b/tslang/lib/TypeScript/ProcessHeapPass.cpp @@ -138,18 +138,28 @@ class ProcessHeapPass : public mlir::PassWrapper 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)); diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index dee036729..444b102db 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -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") @@ -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") @@ -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=$ + "-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 diff --git a/tslang/test/tester/fresh-objects-folded.cmake b/tslang/test/tester/fresh-objects-folded.cmake new file mode 100644 index 000000000..91992ca23 --- /dev/null +++ b/tslang/test/tester/fresh-objects-folded.cmake @@ -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() diff --git a/tslang/test/tester/tests/00fresh_objects_folded.ts b/tslang/test/tester/tests/00fresh_objects_folded.ts new file mode 100644 index 000000000..257595db8 --- /dev/null +++ b/tslang/test/tester/tests/00fresh_objects_folded.ts @@ -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."); +}