Skip to content
Draft
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
112 changes: 112 additions & 0 deletions python315-sampling-profiler/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# Python 3.15 Preview: Sampling Profiler

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

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:

- A **pure-Python hotspot** in the vertex transformation math (`engine.py`)
- A **native-code hotspot** in the NumPy-based lighting (`shading.py`)
- An **I/O-bound bottleneck** that flushes telemetry to disk on every frame (`telemetry.py`)
- 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`)

## Setup

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:

```sh
$ uv sync
```

## Running the Viewer

Open the animation in a window:

```sh
$ uv run pretzel
```

Render a fixed number of frames without a window, which is handy for repeatable profiling runs:

```sh
$ uv run pretzel --frames 300
```

Both modes accept `--fast`, which switches to the optimized asset loader and buffered telemetry:

```sh
$ uv run pretzel --frames 300 --fast
```

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:

```sh
$ uv run pretzel --frames 300 --cache-colors
```

## Profiling the Viewer

The commands below mirror the tutorial. Run them from this directory:

```sh
# Profile a complete headless run:
$ uv run python -m profiling.sampling run -m pretzel --frames 300

# Only count samples where the main thread runs on the CPU:
$ uv run python -m profiling.sampling run --mode cpu -m pretzel --frames 300

# Sample the background thread, too:
$ uv run python -m profiling.sampling run -a -m pretzel --frames 300

# Generate an interactive flame graph:
$ uv run python -m profiling.sampling run --flamegraph -o flamegraph.html \
-m pretzel --frames 300

# Generate a line-level heatmap:
$ uv run python -m profiling.sampling run --heatmap -o heatmap \
-m pretzel --frames 300

# Record a binary profile, then convert it later:
$ uv run python -m profiling.sampling run --binary -o slow.bin \
-m pretzel --frames 300
$ uv run python -m profiling.sampling replay slow.bin

# Compare the in-place color-cache fix against the recorded baseline:
$ uv run python -m profiling.sampling run --diff-flamegraph slow.bin \
-o diff-colors.html -m pretzel --frames 300 --cache-colors

# Compare the parser and telemetry fixes against the same baseline:
$ uv run python -m profiling.sampling run --diff-flamegraph slow.bin \
-o diff.html -m pretzel --frames 300 --fast
```

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`:

```sh
$ sudo .venv/bin/python -m profiling.sampling attach $(pgrep -n -f "pretzel$")
$ sudo .venv/bin/python -m profiling.sampling dump $(pgrep -n -f "pretzel$")
$ sudo .venv/bin/python -m profiling.sampling attach --live \
$(pgrep -n -f "pretzel$")
```

## Profiling the Async Example

The `examples/fetch_textures.py` script simulates concurrent texture downloads:

```sh
$ uv run python -m profiling.sampling run --async-aware \
examples/fetch_textures.py
$ uv run python -m profiling.sampling run --async-aware --async-mode all \
examples/fetch_textures.py
```

## Regenerating the Assets

The model and wallpaper files under `src/pretzel/assets/` are checked in, but you can regenerate them at any time:

```sh
$ uv run make_assets.py
```

## About the .mdl Format

`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.
38 changes: 38 additions & 0 deletions python315-sampling-profiler/examples/fetch_textures.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Download and decode textures concurrently with asyncio."""

import asyncio

CATALOG = {
"bakery-dawn": 0.35,
"bakery-noon": 0.15,
"bakery-dusk": 0.25,
}


async def download_texture(name: str, latency: float) -> bytes:
await asyncio.sleep(latency)
return name.encode() * 100_000


async def decode_texture(payload: bytes) -> int:
checksum = 0
for byte in payload:
checksum = (checksum * 31 + byte) % 1_000_003
return checksum


async def main() -> None:
async with asyncio.TaskGroup() as group:
downloads = {
name: group.create_task(
download_texture(name, latency), name=f"download-{name}"
)
for name, latency in CATALOG.items()
}
for name, download in downloads.items():
checksum = await decode_texture(download.result())
print(f"{name}: {checksum}")


if __name__ == "__main__":
asyncio.run(main())
157 changes: 157 additions & 0 deletions python315-sampling-profiler/make_assets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
"""Generate Pretzel's assets: a trefoil-knot mesh and background wallpapers.
The generated files are already checked into version control, so you only
need to run this script if you want to tweak the assets:
$ uv run make_assets.py
"""

import math
import pathlib
from typing import Final

type Vector = tuple[float, float, float]
type Face = tuple[int, int, int]
type Color = tuple[int, int, int]

ASSETS_DIR = pathlib.Path(__file__).parent / "src" / "pretzel" / "assets"

SEGMENTS: Final = 240
RING_POINTS: Final = 16
TUBE_RADIUS: Final = 0.62

WALLPAPER_SIZE: Final = 512
WALLPAPERS: Final = {
"bakery_dawn.ppm": ((252, 210, 153), (146, 90, 118), (255, 236, 179)),
"bakery_noon.ppm": ((214, 235, 251), (245, 232, 201), (255, 255, 224)),
"bakery_dusk.ppm": ((94, 63, 107), (233, 156, 90), (255, 214, 138)),
}


def main() -> None:
ASSETS_DIR.mkdir(parents=True, exist_ok=True)
write_mesh(ASSETS_DIR / "pretzel.mdl")
for name, (top, bottom, glow) in WALLPAPERS.items():
write_wallpaper(ASSETS_DIR / name, top, bottom, glow)


def write_mesh(path: pathlib.Path) -> None:
vertices, faces = sweep_tube()
with path.open("w", encoding="utf-8") as file:
file.write(f"# Trefoil-knot pretzel: {len(vertices)} vertices,")
file.write(f" {len(faces)} faces\n")
for x, y, z in vertices:
file.write(f"v {x:.6f} {y:.6f} {z:.6f}\n")
for a, b, c in faces:
file.write(f"f {a + 1} {b + 1} {c + 1}\n")
print(f"Wrote {path} ({len(vertices)} vertices, {len(faces)} faces)")


def write_wallpaper(
path: pathlib.Path, top: Color, bottom: Color, glow: Color
) -> None:
size = WALLPAPER_SIZE
lights = [
(size * 0.25, size * 0.3, size * 0.22),
(size * 0.7, size * 0.55, size * 0.3),
(size * 0.45, size * 0.8, size * 0.18),
]
rows = bytearray()
for y in range(size):
vertical = y / (size - 1)
base = blend(top, bottom, vertical)
for x in range(size):
halo = 0.0
for cx, cy, radius in lights:
distance = math.hypot(x - cx, y - cy)
halo += max(0.0, 1.0 - distance / radius) ** 2
color = blend(base, glow, min(1.0, halo))
rows.extend(color)
with path.open("wb") as file:
file.write(b"P6\n%d %d\n255\n" % (size, size))
file.write(rows)
print(f"Wrote {path} ({size}x{size})")


def sweep_tube() -> tuple[list[Vector], list[Face]]:
step = 2.0 * math.pi / SEGMENTS
centers = [trace_trefoil(index * step) for index in range(SEGMENTS)]
tangents = [
normalize(subtract(centers[(i + 1) % SEGMENTS], centers[i - 1]))
for i in range(SEGMENTS)
]
normal = normalize(cross(tangents[0], (0.0, 0.0, 1.0)))
vertices = []
for center, tangent in zip(centers, tangents):
projection = dot(normal, tangent)
normal = normalize(
(
normal[0] - projection * tangent[0],
normal[1] - projection * tangent[1],
normal[2] - projection * tangent[2],
)
)
binormal = cross(tangent, normal)
for point in range(RING_POINTS):
angle = 2.0 * math.pi * point / RING_POINTS
radial = math.cos(angle), math.sin(angle)
vertices.append(
tuple(
center[axis]
+ TUBE_RADIUS
* (radial[0] * normal[axis] + radial[1] * binormal[axis])
for axis in range(3)
)
)
faces = []
for segment in range(SEGMENTS):
next_segment = (segment + 1) % SEGMENTS
for point in range(RING_POINTS):
next_point = (point + 1) % RING_POINTS
a = segment * RING_POINTS + point
b = segment * RING_POINTS + next_point
c = next_segment * RING_POINTS + point
d = next_segment * RING_POINTS + next_point
faces.append((a, c, b))
faces.append((b, c, d))
return vertices, faces


def blend(low: Color, high: Color, amount: float) -> Color:
return tuple(
round(low[channel] + (high[channel] - low[channel]) * amount)
for channel in range(3)
)


def trace_trefoil(t: float) -> Vector:
return (
math.sin(t) + 2.0 * math.sin(2.0 * t),
math.cos(t) - 2.0 * math.cos(2.0 * t),
-math.sin(3.0 * t) * 1.2,
)


def subtract(a: Vector, b: Vector) -> Vector:
return (a[0] - b[0], a[1] - b[1], a[2] - b[2])


def cross(a: Vector, b: Vector) -> Vector:
return (
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
)


def dot(a: Vector, b: Vector) -> float:
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]


def normalize(vector: Vector) -> Vector:
length = math.sqrt(dot(vector, vector)) or 1.0
return (vector[0] / length, vector[1] / length, vector[2] / length)


if __name__ == "__main__":
main()
14 changes: 14 additions & 0 deletions python315-sampling-profiler/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[project]
name = "pretzel"
version = "0.1.0"
description = "A tiny 3D model viewer with deliberately planted bottlenecks"
readme = "README.md"
requires-python = ">=3.15"
dependencies = ["numpy>=2.5.1"]

[project.scripts]
pretzel = "pretzel.cli:main"

[build-system]
requires = ["uv_build>=0.11,<0.13"]
build-backend = "uv_build"
3 changes: 3 additions & 0 deletions python315-sampling-profiler/src/pretzel/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Pretzel: a tiny 3D model viewer with deliberately planted bottlenecks."""

__version__ = "0.1.0"
4 changes: 4 additions & 0 deletions python315-sampling-profiler/src/pretzel/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from pretzel.cli import main

if __name__ == "__main__":
main()
Loading
Loading