Skip to content

Commit b474a7c

Browse files
bzaczynskirealpython-botclaude
authored
Materials for Python 3.15 Preview: Sampling Profiler (#792)
* Materials for Python 3.15 Preview: Sampling Profiler * Pretzel project * Reformat code * Model the mesh and background as dataclasses Replace the hand-rolled Mesh class and the SimpleNamespace background with dataclasses, and introduce PEP 695 type aliases for the geometry primitives. No behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add type hints across the Pretzel package Annotate all function signatures, mark module-level constants as Final, and reuse the geometry aliases from pretzel.engine. The make_assets script gets its own local aliases. No behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Restructure the CLI and polygon color formatting Extract parse_args() from main(), unfold the color-cache conditional into a plain if/else inside the render loop, wire --cache-colors through the windowed viewer, and order helpers below their callers in assets.py and make_assets.py. No behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Update README * Update README * Pin Python version (previously git-ignored) --------- Co-authored-by: realpython-bot <realpython-bot@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent d4c1d27 commit b474a7c

20 files changed

Lines changed: 12345 additions & 0 deletions
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.15
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# Python 3.15 Preview: Sampling Profiler
2+
3+
Supporting code for the Real Python tutorial [Python 3.15 Preview: Sampling Profiler](https://realpython.com/python315-sampling-profiler/).
4+
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+
You can also use `--cache-colors` to memoize 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 -n -f "pretzel$")
86+
$ sudo .venv/bin/python -m profiling.sampling dump $(pgrep -n -f "pretzel$")
87+
$ sudo .venv/bin/python -m profiling.sampling attach --live \
88+
$(pgrep -n -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 faces or 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: str, latency: float) -> bytes:
13+
await asyncio.sleep(latency)
14+
return name.encode() * 100_000
15+
16+
17+
async def decode_texture(payload: bytes) -> int:
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() -> None:
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: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
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+
from typing import Final
12+
13+
type Vector = tuple[float, float, float]
14+
type Face = tuple[int, int, int]
15+
type Color = tuple[int, int, int]
16+
17+
ASSETS_DIR = pathlib.Path(__file__).parent / "src" / "pretzel" / "assets"
18+
19+
SEGMENTS: Final = 240
20+
RING_POINTS: Final = 16
21+
TUBE_RADIUS: Final = 0.62
22+
23+
WALLPAPER_SIZE: Final = 512
24+
WALLPAPERS: Final = {
25+
"bakery_dawn.ppm": ((252, 210, 153), (146, 90, 118), (255, 236, 179)),
26+
"bakery_noon.ppm": ((214, 235, 251), (245, 232, 201), (255, 255, 224)),
27+
"bakery_dusk.ppm": ((94, 63, 107), (233, 156, 90), (255, 214, 138)),
28+
}
29+
30+
31+
def main() -> None:
32+
ASSETS_DIR.mkdir(parents=True, exist_ok=True)
33+
write_mesh(ASSETS_DIR / "pretzel.mdl")
34+
for name, (top, bottom, glow) in WALLPAPERS.items():
35+
write_wallpaper(ASSETS_DIR / name, top, bottom, glow)
36+
37+
38+
def write_mesh(path: pathlib.Path) -> None:
39+
vertices, faces = sweep_tube()
40+
with path.open("w", encoding="utf-8") as file:
41+
file.write(f"# Trefoil-knot pretzel: {len(vertices)} vertices,")
42+
file.write(f" {len(faces)} faces\n")
43+
for x, y, z in vertices:
44+
file.write(f"v {x:.6f} {y:.6f} {z:.6f}\n")
45+
for a, b, c in faces:
46+
file.write(f"f {a + 1} {b + 1} {c + 1}\n")
47+
print(f"Wrote {path} ({len(vertices)} vertices, {len(faces)} faces)")
48+
49+
50+
def write_wallpaper(
51+
path: pathlib.Path, top: Color, bottom: Color, glow: Color
52+
) -> None:
53+
size = WALLPAPER_SIZE
54+
lights = [
55+
(size * 0.25, size * 0.3, size * 0.22),
56+
(size * 0.7, size * 0.55, size * 0.3),
57+
(size * 0.45, size * 0.8, size * 0.18),
58+
]
59+
rows = bytearray()
60+
for y in range(size):
61+
vertical = y / (size - 1)
62+
base = blend(top, bottom, vertical)
63+
for x in range(size):
64+
halo = 0.0
65+
for cx, cy, radius in lights:
66+
distance = math.hypot(x - cx, y - cy)
67+
halo += max(0.0, 1.0 - distance / radius) ** 2
68+
color = blend(base, glow, min(1.0, halo))
69+
rows.extend(color)
70+
with path.open("wb") as file:
71+
file.write(b"P6\n%d %d\n255\n" % (size, size))
72+
file.write(rows)
73+
print(f"Wrote {path} ({size}x{size})")
74+
75+
76+
def sweep_tube() -> tuple[list[Vector], list[Face]]:
77+
step = 2.0 * math.pi / SEGMENTS
78+
centers = [trace_trefoil(index * step) for index in range(SEGMENTS)]
79+
tangents = [
80+
normalize(subtract(centers[(i + 1) % SEGMENTS], centers[i - 1]))
81+
for i in range(SEGMENTS)
82+
]
83+
normal = normalize(cross(tangents[0], (0.0, 0.0, 1.0)))
84+
vertices = []
85+
for center, tangent in zip(centers, tangents):
86+
projection = dot(normal, tangent)
87+
normal = normalize(
88+
(
89+
normal[0] - projection * tangent[0],
90+
normal[1] - projection * tangent[1],
91+
normal[2] - projection * tangent[2],
92+
)
93+
)
94+
binormal = cross(tangent, normal)
95+
for point in range(RING_POINTS):
96+
angle = 2.0 * math.pi * point / RING_POINTS
97+
radial = math.cos(angle), math.sin(angle)
98+
vertices.append(
99+
tuple(
100+
center[axis]
101+
+ TUBE_RADIUS
102+
* (radial[0] * normal[axis] + radial[1] * binormal[axis])
103+
for axis in range(3)
104+
)
105+
)
106+
faces = []
107+
for segment in range(SEGMENTS):
108+
next_segment = (segment + 1) % SEGMENTS
109+
for point in range(RING_POINTS):
110+
next_point = (point + 1) % RING_POINTS
111+
a = segment * RING_POINTS + point
112+
b = segment * RING_POINTS + next_point
113+
c = next_segment * RING_POINTS + point
114+
d = next_segment * RING_POINTS + next_point
115+
faces.append((a, c, b))
116+
faces.append((b, c, d))
117+
return vertices, faces
118+
119+
120+
def blend(low: Color, high: Color, amount: float) -> Color:
121+
return tuple(
122+
round(low[channel] + (high[channel] - low[channel]) * amount)
123+
for channel in range(3)
124+
)
125+
126+
127+
def trace_trefoil(t: float) -> Vector:
128+
return (
129+
math.sin(t) + 2.0 * math.sin(2.0 * t),
130+
math.cos(t) - 2.0 * math.cos(2.0 * t),
131+
-math.sin(3.0 * t) * 1.2,
132+
)
133+
134+
135+
def subtract(a: Vector, b: Vector) -> Vector:
136+
return (a[0] - b[0], a[1] - b[1], a[2] - b[2])
137+
138+
139+
def cross(a: Vector, b: Vector) -> Vector:
140+
return (
141+
a[1] * b[2] - a[2] * b[1],
142+
a[2] * b[0] - a[0] * b[2],
143+
a[0] * b[1] - a[1] * b[0],
144+
)
145+
146+
147+
def dot(a: Vector, b: Vector) -> float:
148+
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
149+
150+
151+
def normalize(vector: Vector) -> Vector:
152+
length = math.sqrt(dot(vector, vector)) or 1.0
153+
return (vector[0] / length, vector[1] / length, vector[2] / length)
154+
155+
156+
if __name__ == "__main__":
157+
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)