Skip to content

Commit 968b336

Browse files
committed
Pretzel project
1 parent b5e9161 commit 968b336

19 files changed

Lines changed: 12254 additions & 1 deletion
Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,112 @@
11
# Python 3.15 Preview: Sampling Profiler
22

3-
Supporting materials for the Real Python tutorial [Python 3.15 Preview: Sampling Profiler](https://realpython.com/python315-sampling-profiler/).
3+
Supporting code for the Real Python tutorial [Python 3.15 Preview: Sampling Profiler](https://realpython.com/python315-sampling-profiler/).
44

5+
Pretzel is a tiny 3D model viewer that spins a trefoil-knot pretzel in a Tkinter window. It ships with a few deliberately planted performance bottlenecks so that every feature of the new `profiling.sampling` profiler has something real to find:
6+
7+
- A **pure-Python hotspot** in the vertex transformation math (`engine.py`)
8+
- A **native-code hotspot** in the NumPy-based lighting (`shading.py`)
9+
- An **I/O-bound bottleneck** that flushes telemetry to disk on every frame (`telemetry.py`)
10+
- A **wasteful asset parser** that reads wallpapers one byte at a time, both at startup and in a background thread (`assets.py` and `streaming.py`)
11+
12+
## Setup
13+
14+
Install [uv](https://docs.astral.sh/uv/), then create the virtual environment. Because `pyproject.toml` pins `requires-python = ">=3.15"`, uv downloads a pre-built CPython 3.15 pre-release for you if you don't have one yet:
15+
16+
```sh
17+
$ uv sync
18+
```
19+
20+
## Running the Viewer
21+
22+
Open the animation in a window:
23+
24+
```sh
25+
$ uv run pretzel
26+
```
27+
28+
Render a fixed number of frames without a window, which is handy for repeatable profiling runs:
29+
30+
```sh
31+
$ uv run pretzel --frames 300
32+
```
33+
34+
Both modes accept `--fast`, which switches to the optimized asset loader and buffered telemetry:
35+
36+
```sh
37+
$ uv run pretzel --frames 300 --fast
38+
```
39+
40+
Headless mode also accepts `--cache-colors`, which memoizes the polygon color formatting in `render.py`. It's the in-place fix showcased by the tutorial's first differential flame graph:
41+
42+
```sh
43+
$ uv run pretzel --frames 300 --cache-colors
44+
```
45+
46+
## Profiling the Viewer
47+
48+
The commands below mirror the tutorial. Run them from this directory:
49+
50+
```sh
51+
# Profile a complete headless run:
52+
$ uv run python -m profiling.sampling run -m pretzel --frames 300
53+
54+
# Only count samples where the main thread runs on the CPU:
55+
$ uv run python -m profiling.sampling run --mode cpu -m pretzel --frames 300
56+
57+
# Sample the background thread, too:
58+
$ uv run python -m profiling.sampling run -a -m pretzel --frames 300
59+
60+
# Generate an interactive flame graph:
61+
$ uv run python -m profiling.sampling run --flamegraph -o flamegraph.html \
62+
-m pretzel --frames 300
63+
64+
# Generate a line-level heatmap:
65+
$ uv run python -m profiling.sampling run --heatmap -o heatmap \
66+
-m pretzel --frames 300
67+
68+
# Record a binary profile, then convert it later:
69+
$ uv run python -m profiling.sampling run --binary -o slow.bin \
70+
-m pretzel --frames 300
71+
$ uv run python -m profiling.sampling replay slow.bin
72+
73+
# Compare the in-place color-cache fix against the recorded baseline:
74+
$ uv run python -m profiling.sampling run --diff-flamegraph slow.bin \
75+
-o diff-colors.html -m pretzel --frames 300 --cache-colors
76+
77+
# Compare the parser and telemetry fixes against the same baseline:
78+
$ uv run python -m profiling.sampling run --diff-flamegraph slow.bin \
79+
-o diff.html -m pretzel --frames 300 --fast
80+
```
81+
82+
To attach to a running viewer, start `uv run pretzel` in one terminal and run one of the following commands in another terminal. The attaching interpreter must be the same Python version as the target, which is why these commands point `sudo` at the interpreter inside `.venv`:
83+
84+
```sh
85+
$ sudo .venv/bin/python -m profiling.sampling attach $(pgrep -f "pretzel$")
86+
$ sudo .venv/bin/python -m profiling.sampling dump $(pgrep -f "pretzel$")
87+
$ sudo .venv/bin/python -m profiling.sampling attach --live \
88+
$(pgrep -f "pretzel$")
89+
```
90+
91+
## Profiling the Async Example
92+
93+
The `examples/fetch_textures.py` script simulates concurrent texture downloads:
94+
95+
```sh
96+
$ uv run python -m profiling.sampling run --async-aware \
97+
examples/fetch_textures.py
98+
$ uv run python -m profiling.sampling run --async-aware --async-mode all \
99+
examples/fetch_textures.py
100+
```
101+
102+
## Regenerating the Assets
103+
104+
The model and wallpaper files under `src/pretzel/assets/` are checked in, but you can regenerate them at any time:
105+
106+
```sh
107+
$ uv run make_assets.py
108+
```
109+
110+
## About the .mdl Format
111+
112+
`pretzel.mdl` uses a minimal text format inspired by Wavefront OBJ: lines starting with `v` define `x y z` vertices, and lines starting with `f` define triangles as 1-based vertex indices.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""Download and decode textures concurrently with asyncio."""
2+
3+
import asyncio
4+
5+
CATALOG = {
6+
"bakery-dawn": 0.35,
7+
"bakery-noon": 0.15,
8+
"bakery-dusk": 0.25,
9+
}
10+
11+
12+
async def download_texture(name, latency):
13+
await asyncio.sleep(latency)
14+
return name.encode() * 100_000
15+
16+
17+
async def decode_texture(payload):
18+
checksum = 0
19+
for byte in payload:
20+
checksum = (checksum * 31 + byte) % 1_000_003
21+
return checksum
22+
23+
24+
async def main():
25+
async with asyncio.TaskGroup() as group:
26+
downloads = {
27+
name: group.create_task(
28+
download_texture(name, latency), name=f"download-{name}"
29+
)
30+
for name, latency in CATALOG.items()
31+
}
32+
for name, download in downloads.items():
33+
checksum = await decode_texture(download.result())
34+
print(f"{name}: {checksum}")
35+
36+
37+
if __name__ == "__main__":
38+
asyncio.run(main())
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
"""Generate Pretzel's assets: a trefoil-knot mesh and background wallpapers.
2+
3+
The generated files are already checked into version control, so you only
4+
need to run this script if you want to tweak the assets:
5+
6+
$ uv run make_assets.py
7+
"""
8+
9+
import math
10+
import pathlib
11+
12+
ASSETS_DIR = pathlib.Path(__file__).parent / "src" / "pretzel" / "assets"
13+
14+
SEGMENTS = 240
15+
RING_POINTS = 16
16+
TUBE_RADIUS = 0.62
17+
18+
WALLPAPER_SIZE = 512
19+
WALLPAPERS = {
20+
"bakery_dawn.ppm": ((252, 210, 153), (146, 90, 118), (255, 236, 179)),
21+
"bakery_noon.ppm": ((214, 235, 251), (245, 232, 201), (255, 255, 224)),
22+
"bakery_dusk.ppm": ((94, 63, 107), (233, 156, 90), (255, 214, 138)),
23+
}
24+
25+
26+
def trace_trefoil(t):
27+
return (
28+
math.sin(t) + 2.0 * math.sin(2.0 * t),
29+
math.cos(t) - 2.0 * math.cos(2.0 * t),
30+
-math.sin(3.0 * t) * 1.2,
31+
)
32+
33+
34+
def subtract(a, b):
35+
return (a[0] - b[0], a[1] - b[1], a[2] - b[2])
36+
37+
38+
def cross(a, b):
39+
return (
40+
a[1] * b[2] - a[2] * b[1],
41+
a[2] * b[0] - a[0] * b[2],
42+
a[0] * b[1] - a[1] * b[0],
43+
)
44+
45+
46+
def dot(a, b):
47+
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
48+
49+
50+
def normalize(vector):
51+
length = math.sqrt(dot(vector, vector)) or 1.0
52+
return (vector[0] / length, vector[1] / length, vector[2] / length)
53+
54+
55+
def sweep_tube():
56+
step = 2.0 * math.pi / SEGMENTS
57+
centers = [trace_trefoil(index * step) for index in range(SEGMENTS)]
58+
tangents = [
59+
normalize(subtract(centers[(i + 1) % SEGMENTS], centers[i - 1]))
60+
for i in range(SEGMENTS)
61+
]
62+
normal = normalize(cross(tangents[0], (0.0, 0.0, 1.0)))
63+
vertices = []
64+
for center, tangent in zip(centers, tangents):
65+
projection = dot(normal, tangent)
66+
normal = normalize(
67+
(
68+
normal[0] - projection * tangent[0],
69+
normal[1] - projection * tangent[1],
70+
normal[2] - projection * tangent[2],
71+
)
72+
)
73+
binormal = cross(tangent, normal)
74+
for point in range(RING_POINTS):
75+
angle = 2.0 * math.pi * point / RING_POINTS
76+
radial = math.cos(angle), math.sin(angle)
77+
vertices.append(
78+
tuple(
79+
center[axis]
80+
+ TUBE_RADIUS
81+
* (radial[0] * normal[axis] + radial[1] * binormal[axis])
82+
for axis in range(3)
83+
)
84+
)
85+
faces = []
86+
for segment in range(SEGMENTS):
87+
next_segment = (segment + 1) % SEGMENTS
88+
for point in range(RING_POINTS):
89+
next_point = (point + 1) % RING_POINTS
90+
a = segment * RING_POINTS + point
91+
b = segment * RING_POINTS + next_point
92+
c = next_segment * RING_POINTS + point
93+
d = next_segment * RING_POINTS + next_point
94+
faces.append((a, c, b))
95+
faces.append((b, c, d))
96+
return vertices, faces
97+
98+
99+
def write_mesh(path):
100+
vertices, faces = sweep_tube()
101+
with path.open("w", encoding="utf-8") as file:
102+
file.write(f"# Trefoil-knot pretzel: {len(vertices)} vertices,")
103+
file.write(f" {len(faces)} faces\n")
104+
for x, y, z in vertices:
105+
file.write(f"v {x:.6f} {y:.6f} {z:.6f}\n")
106+
for a, b, c in faces:
107+
file.write(f"f {a + 1} {b + 1} {c + 1}\n")
108+
print(f"Wrote {path} ({len(vertices)} vertices, {len(faces)} faces)")
109+
110+
111+
def blend(low, high, amount):
112+
return tuple(
113+
round(low[channel] + (high[channel] - low[channel]) * amount)
114+
for channel in range(3)
115+
)
116+
117+
118+
def write_wallpaper(path, top, bottom, glow):
119+
size = WALLPAPER_SIZE
120+
lights = [
121+
(size * 0.25, size * 0.3, size * 0.22),
122+
(size * 0.7, size * 0.55, size * 0.3),
123+
(size * 0.45, size * 0.8, size * 0.18),
124+
]
125+
rows = bytearray()
126+
for y in range(size):
127+
vertical = y / (size - 1)
128+
base = blend(top, bottom, vertical)
129+
for x in range(size):
130+
halo = 0.0
131+
for cx, cy, radius in lights:
132+
distance = math.hypot(x - cx, y - cy)
133+
halo += max(0.0, 1.0 - distance / radius) ** 2
134+
color = blend(base, glow, min(1.0, halo))
135+
rows.extend(color)
136+
with path.open("wb") as file:
137+
file.write(b"P6\n%d %d\n255\n" % (size, size))
138+
file.write(rows)
139+
print(f"Wrote {path} ({size}x{size})")
140+
141+
142+
def main():
143+
ASSETS_DIR.mkdir(parents=True, exist_ok=True)
144+
write_mesh(ASSETS_DIR / "pretzel.mdl")
145+
for name, (top, bottom, glow) in WALLPAPERS.items():
146+
write_wallpaper(ASSETS_DIR / name, top, bottom, glow)
147+
148+
149+
if __name__ == "__main__":
150+
main()
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
[project]
2+
name = "pretzel"
3+
version = "0.1.0"
4+
description = "A tiny 3D model viewer with deliberately planted bottlenecks"
5+
readme = "README.md"
6+
requires-python = ">=3.15"
7+
dependencies = ["numpy>=2.5.1"]
8+
9+
[project.scripts]
10+
pretzel = "pretzel.cli:main"
11+
12+
[build-system]
13+
requires = ["uv_build>=0.11,<0.13"]
14+
build-backend = "uv_build"
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""Pretzel: a tiny 3D model viewer with deliberately planted bottlenecks."""
2+
3+
__version__ = "0.1.0"
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from pretzel.cli import main
2+
3+
if __name__ == "__main__":
4+
main()

0 commit comments

Comments
 (0)