Skip to content

Commit 35ac7ff

Browse files
Ray tracer benchmark: memory models vs Rust (#634)
* Ray tracer benchmark: memory models vs Rust tslang/benchmarks/raytrace holds the raytrace.ts test at 512x512 with a pixel checksum, an idiomatic Rust port, a Rust port that heap-allocates every object through Rc, and bench.ps1, which builds them all, checks the checksum and times them. README records the 2026-10-21 results. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * README: ray tracer results per memory model vs Rust Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Ray tracer benchmark: add C++ ports cpp/idiomatic.cpp (value structs, unique_ptr things) and cpp/shared.cpp (every object a make_shared, std::function surfaces), built by bench.ps1 with clang++ -O3 and MSVC /O2. Both READMEs carry the new numbers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 49b5f6b commit 35ac7ff

10 files changed

Lines changed: 1445 additions & 0 deletions

File tree

‎README.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -756,6 +756,30 @@ freed under it**, which is the same trade Swift makes with ARC.
756756
See **[docs/memory-models.md](docs/memory-models.md)** for which shapes leak, which do not, and
757757
what to do about it.
758758

759+
### Performance
760+
761+
The ray tracer benchmark in [tslang/benchmarks/raytrace](tslang/benchmarks/raytrace/README.md)
762+
(`raytrace.ts` at 512x512, median of 7 runs, 2026-10-21) against Rust and C++ ports of the same
763+
code, each written twice: with plain value vectors and colors, and with every object
764+
reference-counted on the heap the way tslang allocates them:
765+
766+
| build | time | peak memory | vs C++ values (clang -O3) |
767+
| --- | --- | --- | --- |
768+
| `-mm=gc` | 1298 ms | 6 MB | 26x slower |
769+
| `-mm=rc` | 599 ms | 4 MB | 12x slower |
770+
| `-mm=none` | 703 ms | 845 MB | 14x slower |
771+
| `-mm=own` | does not compile yet | | |
772+
| C++, values (clang / MSVC) | 51 / 69 ms | | |
773+
| Rust, values (`Copy` vectors and colors) | 55 ms | 4 MB | 1.1x slower |
774+
| Rust, every object in an `Rc` | 492 ms | 4 MB | 9.7x slower |
775+
| C++, every object in a `shared_ptr` (clang / MSVC) | 830 / 899 ms | | 16x slower |
776+
777+
Almost all of the gap is allocation: every vector and color the program computes is a heap object.
778+
Rust that allocates the same way is only 1.2x faster than `-mm=rc`, and C++ with `shared_ptr`
779+
(atomic counts) is 1.4x slower than it. `-mm=none` is slower than
780+
`-mm=rc` because it never frees and keeps touching fresh pages. Stack promotion makes no measurable
781+
difference here yet: the hot allocations are returned from functions or captured by closures.
782+
759783
### `-mm=own`
760784

761785
`-mm=own` works the way Rust's borrow checker does. Every heap block has exactly one owner: a
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
out/
2+
rust/target/
3+
rust/Cargo.lock
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Ray tracer: memory models vs Rust and C++
2+
3+
`rtbench.ts` is `test/tester/tests/raytrace.ts` at 512x512 with a pixel checksum (see its header).
4+
`rust/` and `cpp/` each hold two ports of the same algorithm:
5+
6+
- `rust/src/bin/idiomatic.rs`, `cpp/idiomatic.cpp`: `Vector`/`Color` are plain values, things are
7+
`Box<dyn Thing>` / `std::unique_ptr<Thing>`, surfaces hold function pointers. This is what a
8+
Rust or C++ programmer would write.
9+
- `rust/src/bin/boxed.rs`, `cpp/shared.cpp`: every `Vector`, `Color`, `Ray` and `Intersection` is
10+
a fresh reference-counted heap object (`Rc` / `std::make_shared`), things are shared too, and
11+
surface members are `Rc<dyn Fn>` / `std::function`. This is roughly the allocation pattern
12+
tslang produces, so the gap between a language's two ports is the cost of allocation alone.
13+
`Rc` counts are plain integers; `shared_ptr` counts are atomic.
14+
15+
Run (needs a Release tslang and cargo; clang++ on PATH and Visual Studio are used when present):
16+
17+
```
18+
pwsh tslang/benchmarks/raytrace/bench.ps1
19+
```
20+
21+
- tslang: `--emit=exe --opt --opt_level=3 --no-default-lib --mm=<model>` (`--no-default-lib`
22+
because the file declares its own `sqrt`/`floor`/`pow`/`fabs`)
23+
- Rust: `opt-level=3`, `lto="fat"`, `codegen-units=1`, `panic="abort"`
24+
- C++: `clang++ -std=c++20 -O3`, and `cl /std:c++20 /O2 /EHsc /MT`
25+
26+
## Results 2026-10-21
27+
28+
main 49b5f6bb, Release, Windows 11 x64; Rust 1.99.0, clang 21.1.4, MSVC from Visual Studio 18;
29+
median of 7 runs after a warm-up. Every build prints the same checksum.
30+
31+
| Build | median | peak working set | vs C++ idiomatic (clang) |
32+
|---|---|---|---|
33+
| tslang gc | 1298 ms | 6 MB | 26x |
34+
| tslang gc, `--no-stack-promotion` | 1341 ms | | |
35+
| tslang rc | 599 ms | 4 MB | 12x |
36+
| tslang rc, `--no-stack-promotion` | 600 ms | | |
37+
| tslang none | 703 ms | 845 MB | 14x |
38+
| tslang none, `--no-stack-promotion` | 727 ms | | |
39+
| tslang own | does not compile | | |
40+
| Rust idiomatic | 55 ms | 4 MB | 1.1x |
41+
| Rust Rc-boxed | 492 ms | 4 MB | 9.7x |
42+
| C++ idiomatic (clang) | 51 ms | | 1x |
43+
| C++ idiomatic (MSVC) | 69 ms | | 1.4x |
44+
| C++ shared_ptr (clang) | 830 ms | | 16x |
45+
| C++ shared_ptr (MSVC) | 899 ms | | 18x |
46+
47+
- Almost all of the gap to Rust and C++ is allocation: their heap-allocating ports are 9-16x
48+
slower than their idiomatic ones. rc is 1.2x slower than Rust with `Rc`, and 1.4x faster than
49+
C++ with `shared_ptr` (atomic counts, `std::function` calls).
50+
- `none` never frees, so it pays for fresh pages and ends up slower than `rc`.
51+
- Stack promotion has no measurable effect here. `--emit=mlir-affine --stack-promotion-report`
52+
under rc keeps every hot-path allocation on the heap: `Vector.*`/`Color.*` results are
53+
"returned", the `Ray`/`Intersection` literals are "converted" (to the interface) or
54+
"recursive", and the `addLight`/`getPoint` closures are "counted-capture".
55+
- `-mm=own` reports about 44 errors: the program shares the `Surfaces` globals, keeps owned values
56+
in object literals, and aliases `Color.black` from two statics.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Builds rtbench.ts under every memory model (with and without stack promotion) and the two Rust
2+
# ports, checks that they all print the same checksum, then times each one: a warm-up run and
3+
# $Runs timed runs, reporting min/median/max wall time.
4+
#
5+
# pwsh tslang/benchmarks/raytrace/bench.ps1 [-Tslang <path to tslang.exe>] [-Runs 7]
6+
param(
7+
[string]$Tslang = "$PSScriptRoot\..\..\..\__build\tslang\windows-msbuild-2026-release\bin\tslang.exe",
8+
[int]$Runs = 7
9+
)
10+
11+
$ErrorActionPreference = "Stop"
12+
$out = Join-Path $PSScriptRoot "out"
13+
New-Item -ItemType Directory -Force $out | Out-Null
14+
15+
$bins = @()
16+
foreach ($mm in "gc", "rc", "none", "own") {
17+
foreach ($sp in $false, $true) {
18+
$name = if ($sp) { "tslang $mm (no SP)" } else { "tslang $mm" }
19+
$exe = Join-Path $out ("rtb_$mm" + $(if ($sp) { "_nsp" } else { "" }) + ".exe")
20+
$flags = @("--emit=exe", "--opt", "--opt_level=3", "--no-default-lib", "--mm=$mm")
21+
if ($sp) { $flags += "--no-stack-promotion" }
22+
& $Tslang @flags "$PSScriptRoot\rtbench.ts" -o $exe 2>&1 | Out-Null
23+
if ($LASTEXITCODE -ne 0) { "{0,-22} does not compile (exit {1})" -f $name, $LASTEXITCODE; continue }
24+
$bins += @{ n = $name; p = $exe }
25+
}
26+
}
27+
28+
Push-Location "$PSScriptRoot\rust"
29+
try { cargo build --release --quiet; if ($LASTEXITCODE -ne 0) { throw "cargo build failed" } } finally { Pop-Location }
30+
$bins += @{ n = "rust idiomatic"; p = "$PSScriptRoot\rust\target\release\idiomatic.exe" }
31+
$bins += @{ n = "rust Rc-boxed"; p = "$PSScriptRoot\rust\target\release\boxed.exe" }
32+
33+
# C++: clang++ -O3 from PATH, and MSVC /O2 through the newest Visual Studio's vcvars64
34+
$vs = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -prerelease -property installationPath
35+
$vcvars = "$vs\VC\Auxiliary\Build\vcvars64.bat"
36+
foreach ($f in "idiomatic", "shared") {
37+
$src = "$PSScriptRoot\cpp\$f.cpp"
38+
if (Get-Command clang++ -ErrorAction SilentlyContinue) {
39+
$exe = "$out\cpp_${f}_clang.exe"
40+
clang++ -std=c++20 -O3 -o $exe $src
41+
if ($LASTEXITCODE -eq 0) { $bins += @{ n = "c++ $f (clang)"; p = $exe } }
42+
}
43+
if (Test-Path $vcvars) {
44+
$exe = "$out\cpp_${f}_msvc.exe"
45+
cmd /c "`"$vcvars`" >nul && cl /nologo /std:c++20 /O2 /EHsc /MT /Fe:`"$exe`" /Fo:`"$out\\`" `"$src`"" | Out-Null
46+
if ($LASTEXITCODE -eq 0) { $bins += @{ n = "c++ $f (msvc)"; p = $exe } }
47+
}
48+
}
49+
50+
foreach ($b in $bins) {
51+
$text = (& $b.p) -join " "
52+
$ok = $text -match "38837369|low digits: 369"
53+
$times = 1..$Runs | ForEach-Object {
54+
$sw = [Diagnostics.Stopwatch]::StartNew(); & $b.p | Out-Null; $sw.Stop(); $sw.Elapsed.TotalMilliseconds
55+
} | Sort-Object
56+
"{0,-22} min {1,8:N1} ms median {2,8:N1} ms max {3,8:N1} ms checksum {4}" -f `
57+
$b.n, $times[0], $times[[int][math]::Floor($Runs / 2)], $times[-1], $(if ($ok) { "ok" } else { "MISMATCH: $text" })
58+
}
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
// Idiomatic C++ port of rtbench.ts: Vector/Color are plain value structs, things are
2+
// std::unique_ptr<Thing> with virtual methods, surfaces hold function pointers.
3+
#include <cmath>
4+
#include <cstdio>
5+
#include <cstdlib>
6+
#include <limits>
7+
#include <memory>
8+
#include <optional>
9+
#include <vector>
10+
11+
struct Vector {
12+
double x, y, z;
13+
static Vector times(double k, Vector v) { return {k * v.x, k * v.y, k * v.z}; }
14+
static Vector minus(Vector a, Vector b) { return {a.x - b.x, a.y - b.y, a.z - b.z}; }
15+
static Vector plus(Vector a, Vector b) { return {a.x + b.x, a.y + b.y, a.z + b.z}; }
16+
static double dot(Vector a, Vector b) { return a.x * b.x + a.y * b.y + a.z * b.z; }
17+
static double mag(Vector v) { return std::sqrt(v.x * v.x + v.y * v.y + v.z * v.z); }
18+
static Vector norm(Vector v) {
19+
double m = mag(v);
20+
double div = (m == 0) ? std::numeric_limits<double>::infinity() : 1.0 / m;
21+
return times(div, v);
22+
}
23+
static Vector cross(Vector a, Vector b) {
24+
return {a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x};
25+
}
26+
};
27+
28+
struct Color {
29+
double r, g, b;
30+
static Color scale(double k, Color v) { return {k * v.r, k * v.g, k * v.b}; }
31+
static Color plus(Color a, Color b) { return {a.r + b.r, a.g + b.g, a.b + b.b}; }
32+
static Color times(Color a, Color b) { return {a.r * b.r, a.g * b.g, a.b * b.b}; }
33+
};
34+
35+
constexpr Color white{1.0, 1.0, 1.0};
36+
constexpr Color grey{0.5, 0.5, 0.5};
37+
constexpr Color black{0.0, 0.0, 0.0};
38+
constexpr Color background = black;
39+
constexpr Color defaultColor = black;
40+
41+
struct DrawingColor { double r, g, b; };
42+
43+
static DrawingColor toDrawingColor(Color c) {
44+
auto legalize = [](double d) { return d > 1 ? 1.0 : d; };
45+
return {std::floor(legalize(c.r) * 255), std::floor(legalize(c.g) * 255), std::floor(legalize(c.b) * 255)};
46+
}
47+
48+
struct Camera {
49+
Vector pos, forward, right, up;
50+
Camera(Vector pos, Vector lookAt) : pos(pos) {
51+
Vector down{0.0, -1.0, 0.0};
52+
forward = Vector::norm(Vector::minus(lookAt, pos));
53+
right = Vector::times(1.5, Vector::norm(Vector::cross(forward, down)));
54+
up = Vector::times(1.5, Vector::norm(Vector::cross(forward, right)));
55+
}
56+
};
57+
58+
struct Ray { Vector start, dir; };
59+
60+
struct Thing;
61+
struct Intersection { const Thing *thing; Ray ray; double dist; };
62+
63+
struct Surface {
64+
Color (*diffuse)(Vector);
65+
Color (*specular)(Vector);
66+
double (*reflect)(Vector);
67+
double roughness;
68+
};
69+
70+
struct Thing {
71+
virtual ~Thing() = default;
72+
virtual std::optional<Intersection> intersect(const Ray &ray) const = 0;
73+
virtual Vector normal(Vector pos) const = 0;
74+
const Surface *surface;
75+
explicit Thing(const Surface *surface) : surface(surface) {}
76+
};
77+
78+
struct Light { Vector pos; Color color; };
79+
80+
struct Scene {
81+
std::vector<std::unique_ptr<Thing>> things;
82+
std::vector<Light> lights;
83+
Camera camera;
84+
};
85+
86+
struct Sphere : Thing {
87+
Vector center;
88+
double radius2;
89+
Sphere(Vector center, double radius, const Surface *surface) : Thing(surface), center(center), radius2(radius * radius) {}
90+
Vector normal(Vector pos) const override { return Vector::norm(Vector::minus(pos, center)); }
91+
std::optional<Intersection> intersect(const Ray &ray) const override {
92+
Vector eo = Vector::minus(center, ray.start);
93+
double v = Vector::dot(eo, ray.dir);
94+
double dist = 0;
95+
if (v >= 0) {
96+
double disc = radius2 - (Vector::dot(eo, eo) - v * v);
97+
if (disc >= 0) dist = v - std::sqrt(disc);
98+
}
99+
if (dist == 0) return std::nullopt;
100+
return Intersection{this, ray, dist};
101+
}
102+
};
103+
104+
struct Plane : Thing {
105+
Vector norm;
106+
double offset;
107+
Plane(Vector norm, double offset, const Surface *surface) : Thing(surface), norm(norm), offset(offset) {}
108+
Vector normal(Vector) const override { return norm; }
109+
std::optional<Intersection> intersect(const Ray &ray) const override {
110+
double denom = Vector::dot(norm, ray.dir);
111+
if (denom > 0) return std::nullopt;
112+
double dist = (Vector::dot(norm, ray.start) + offset) / (-denom);
113+
return Intersection{this, ray, dist};
114+
}
115+
};
116+
117+
static bool odd(Vector pos) { return std::fmod(std::floor(pos.z) + std::floor(pos.x), 2) != 0; }
118+
119+
const Surface shiny{
120+
[](Vector) { return white; },
121+
[](Vector) { return grey; },
122+
[](Vector) { return 0.7; },
123+
250.0};
124+
125+
const Surface checkerboard{
126+
[](Vector pos) { return odd(pos) ? white : black; },
127+
[](Vector) { return white; },
128+
[](Vector pos) { return odd(pos) ? 0.1 : 0.7; },
129+
150.0};
130+
131+
struct RayTracer {
132+
int maxDepth = 5;
133+
134+
std::optional<Intersection> intersections(const Ray &ray, const Scene &scene) const {
135+
double closest = std::numeric_limits<double>::infinity();
136+
std::optional<Intersection> closestInter;
137+
for (auto &thing : scene.things) {
138+
auto inter = thing->intersect(ray);
139+
if (inter && inter->dist < closest) {
140+
closestInter = inter;
141+
closest = inter->dist;
142+
}
143+
}
144+
return closestInter;
145+
}
146+
147+
std::optional<double> testRay(const Ray &ray, const Scene &scene) const {
148+
auto isect = intersections(ray, scene);
149+
if (isect) return isect->dist;
150+
return std::nullopt;
151+
}
152+
153+
Color traceRay(const Ray &ray, const Scene &scene, int depth) const {
154+
auto isect = intersections(ray, scene);
155+
if (!isect) return background;
156+
return shade(*isect, scene, depth);
157+
}
158+
159+
Color shade(const Intersection &isect, const Scene &scene, int depth) const {
160+
Vector d = isect.ray.dir;
161+
Vector pos = Vector::plus(Vector::times(isect.dist, d), isect.ray.start);
162+
Vector normal = isect.thing->normal(pos);
163+
Vector reflectDir = Vector::minus(d, Vector::times(2, Vector::times(Vector::dot(normal, d), normal)));
164+
Color naturalColor = Color::plus(background, getNaturalColor(isect.thing, pos, normal, reflectDir, scene));
165+
Color reflectedColor = (depth >= maxDepth) ? grey : getReflectionColor(isect.thing, pos, normal, reflectDir, scene, depth);
166+
return Color::plus(naturalColor, reflectedColor);
167+
}
168+
169+
Color getReflectionColor(const Thing *thing, Vector pos, Vector, Vector rd, const Scene &scene, int depth) const {
170+
return Color::scale(thing->surface->reflect(pos), traceRay({pos, rd}, scene, depth + 1));
171+
}
172+
173+
Color getNaturalColor(const Thing *thing, Vector pos, Vector norm, Vector rd, const Scene &scene) const {
174+
auto addLight = [&](Color col, const Light &light) {
175+
Vector ldis = Vector::minus(light.pos, pos);
176+
Vector livec = Vector::norm(ldis);
177+
auto neatIsect = testRay({pos, livec}, scene);
178+
bool isInShadow = neatIsect ? (*neatIsect <= Vector::mag(ldis)) : false;
179+
if (isInShadow) return col;
180+
double illum = Vector::dot(livec, norm);
181+
Color lcolor = (illum > 0) ? Color::scale(illum, light.color) : defaultColor;
182+
double specular = Vector::dot(livec, Vector::norm(rd));
183+
Color scolor = (specular > 0) ? Color::scale(std::pow(specular, thing->surface->roughness), light.color) : defaultColor;
184+
return Color::plus(col, Color::plus(Color::times(thing->surface->diffuse(pos), lcolor),
185+
Color::times(thing->surface->specular(pos), scolor)));
186+
};
187+
Color col = defaultColor;
188+
for (auto &light : scene.lights) col = addLight(col, light);
189+
return col;
190+
}
191+
192+
double render(const Scene &scene, double screenWidth, double screenHeight) const {
193+
auto getPoint = [&](double x, double y, const Camera &camera) {
194+
auto recenterX = [&](double x) { return (x - (screenWidth / 2.0)) / 2.0 / screenWidth; };
195+
auto recenterY = [&](double y) { return -(y - (screenHeight / 2.0)) / 2.0 / screenHeight; };
196+
return Vector::norm(Vector::plus(camera.forward, Vector::plus(Vector::times(recenterX(x), camera.right), Vector::times(recenterY(y), camera.up))));
197+
};
198+
double sum = 0;
199+
for (double y = 0; y < screenHeight; y++) {
200+
for (double x = 0; x < screenWidth; x++) {
201+
Color color = traceRay({scene.camera.pos, getPoint(x, y, scene.camera)}, scene, 0);
202+
DrawingColor c = toDrawingColor(color);
203+
sum += c.r + c.g + c.b;
204+
}
205+
}
206+
return sum;
207+
}
208+
};
209+
210+
static Scene defaultScene() {
211+
Scene scene{{}, {}, Camera({3.0, 2.0, 4.0}, {-1.0, 0.5, 0.0})};
212+
scene.things.push_back(std::make_unique<Plane>(Vector{0.0, 1.0, 0.0}, 0.0, &checkerboard));
213+
scene.things.push_back(std::make_unique<Sphere>(Vector{0.0, 1.0, -0.25}, 1.0, &shiny));
214+
scene.things.push_back(std::make_unique<Sphere>(Vector{-6, 3.0, -5.0}, 1.0, &shiny));
215+
scene.things.push_back(std::make_unique<Sphere>(Vector{-1.0, 0.5, 1.5}, 0.5, &shiny));
216+
scene.lights = {{{-2.0, 2.5, 0.0}, {0.49, 0.07, 0.07}},
217+
{{1.5, 2.5, 1.5}, {0.07, 0.07, 0.49}},
218+
{{1.5, 2.5, -1.5}, {0.07, 0.49, 0.071}},
219+
{{0.0, 3.5, 0.0}, {0.21, 0.21, 0.35}}};
220+
return scene;
221+
}
222+
223+
int main(int argc, char **argv) {
224+
std::puts("start...");
225+
double size = argc > 1 ? std::atof(argv[1]) : 512;
226+
RayTracer rayTracer;
227+
double sum = rayTracer.render(defaultScene(), size, size);
228+
std::printf("checksum: %.0f\n", sum);
229+
std::puts("done.");
230+
}

0 commit comments

Comments
 (0)