docs: record sprite draw performance findings
Co-authored-by: codegirl007 <s.raide@gmail.com>
This commit is contained in:
@@ -0,0 +1,79 @@
|
|||||||
|
# Add deterministic CPU and frame benchmarks for sprite rendering
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The current `make flame` workflow is valuable for finding call stacks, but it
|
||||||
|
does not provide a reproducible performance metric:
|
||||||
|
|
||||||
|
- `examples/crowd` draws only 24 sprites.
|
||||||
|
- `MAX_SPRITES` limits the renderer to 128 sprites.
|
||||||
|
- Recording starts an interactive application and asks the user to play for
|
||||||
|
10–20 seconds before quitting.
|
||||||
|
- The two checked-in reports contain only 499 and 469 samples.
|
||||||
|
- CPU queue construction and GPU submission are combined in one profile.
|
||||||
|
- The profiler currently builds unoptimized code.
|
||||||
|
|
||||||
|
These limitations make it difficult to tell whether a change made
|
||||||
|
`draw_sprite` faster, changed driver behavior, or merely changed sampling noise.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
A temporary deterministic benchmark exposed two very different results:
|
||||||
|
|
||||||
|
1. CPU-only `draw_sprite`, two million calls and seven trials:
|
||||||
|
- `-debug`: median 192.560 ns/draw
|
||||||
|
- `-debug -o:speed`: median 19.154 ns/draw
|
||||||
|
2. Full 128-sprite frames through SDL GPU on Lavapipe, 1,000 measured frames
|
||||||
|
and five trials:
|
||||||
|
- `-debug`: median 2.486 ms/frame
|
||||||
|
- `-debug -o:speed`: median 2.447 ms/frame
|
||||||
|
|
||||||
|
At the current cap, optimized CPU queue construction is approximately 3.2
|
||||||
|
microseconds for 128 sprites. The full software-rendered frame is around 2.45
|
||||||
|
milliseconds, so optimizing `draw_sprite` cannot materially improve that
|
||||||
|
specific end-to-end workload. A hardware GPU or a larger future sprite limit
|
||||||
|
may have a different balance.
|
||||||
|
|
||||||
|
This split also explains why percentages from the current unoptimized
|
||||||
|
flamegraphs overstate small helper functions.
|
||||||
|
|
||||||
|
## Proposed change
|
||||||
|
|
||||||
|
Add a non-interactive benchmark target with two explicitly separate workloads.
|
||||||
|
|
||||||
|
### CPU queue benchmark
|
||||||
|
|
||||||
|
- Construct `App`, `Character_Data`, and `Sprite` with real baked metadata.
|
||||||
|
- Use safe fake non-null GPU handles; `draw_sprite` only checks/stores these.
|
||||||
|
- Preallocate the draw list.
|
||||||
|
- Clear the queue whenever it reaches `MAX_SPRITES`.
|
||||||
|
- Vary sprite position between calls so the compiler cannot hoist the work.
|
||||||
|
- Warm up before timing.
|
||||||
|
- Run at least one million calls and report nanoseconds per draw.
|
||||||
|
- Build with `-o:speed` by default.
|
||||||
|
|
||||||
|
### Full-frame benchmark
|
||||||
|
|
||||||
|
- Use a real SDL GPU device and baked texture.
|
||||||
|
- Warm up before timing.
|
||||||
|
- Run a fixed number of frames without interactive input.
|
||||||
|
- Report milliseconds per frame and sprites per second.
|
||||||
|
- Record GPU backend, present mode, compiler version, compiler flags, and sprite
|
||||||
|
count.
|
||||||
|
|
||||||
|
The CPU benchmark should be available without a display or GPU. The full-frame
|
||||||
|
benchmark may remain opt-in where a suitable GPU backend is unavailable.
|
||||||
|
|
||||||
|
Do not add a strict CI regression threshold initially; hosted runner variance
|
||||||
|
will make a single threshold flaky. CI can still compile the benchmark and
|
||||||
|
verify that it completes.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- A Makefile target runs the optimized CPU benchmark non-interactively.
|
||||||
|
- Results include compiler flags, iteration count, median, and per-trial values.
|
||||||
|
- CPU queue time is reported separately from complete frame time.
|
||||||
|
- Sprite positions or frames vary during the measured loop.
|
||||||
|
- The draw list never silently exceeds `MAX_SPRITES`.
|
||||||
|
- The benchmark has documented commands for repeatable local comparison.
|
||||||
|
- `make check` and `make test` continue to pass.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Profile optimized sprite builds instead of unoptimized debug code
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
`make flame-build` currently compiles the selected example with `-debug` but
|
||||||
|
without an optimization mode:
|
||||||
|
|
||||||
|
```make
|
||||||
|
odin build examples/$(FLAME_EXAMPLE) -collection:pkg=. -out:$(FLAME_BIN) -debug
|
||||||
|
```
|
||||||
|
|
||||||
|
This makes the flamegraph useful for debugging but misleading for performance
|
||||||
|
decisions. The current profiles largely describe code that will disappear or
|
||||||
|
be inlined in an optimized build.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
The two checked-in `crowd` profiles were captured from this unoptimized binary.
|
||||||
|
They report:
|
||||||
|
|
||||||
|
- `engine::draw_sprite`: 9.56% and 12.22% self time
|
||||||
|
- `engine::to_clip`: 5.33% and 5.51% self time
|
||||||
|
- `engine::sprite_feet_quad`: 3.98% and 3.30% self time
|
||||||
|
- Additional time in string hashing/map lookup, bounds checks, and dynamic
|
||||||
|
array append helpers
|
||||||
|
|
||||||
|
A CPU-only benchmark using the real `draw_sprite`, real baked toad metadata,
|
||||||
|
preallocated draw list, changing sprite positions, and two million draws per
|
||||||
|
trial produced:
|
||||||
|
|
||||||
|
| Build | Median time per draw | Trials |
|
||||||
|
| --- | ---: | ---: |
|
||||||
|
| `-debug` (current Makefile behavior) | 192.560 ns | 7 |
|
||||||
|
| `-debug -o:speed` | 19.154 ns | 7 |
|
||||||
|
|
||||||
|
The optimized build is about **10.1x faster** without an engine code change.
|
||||||
|
|
||||||
|
An end-to-end 128-sprite benchmark on SDL 3.4.12 with Lavapipe showed only a
|
||||||
|
small full-frame difference (median 2.486 ms debug versus 2.447 ms optimized)
|
||||||
|
because software GPU/driver work dominated. This does not invalidate the CPU
|
||||||
|
result; it shows why CPU queue time and full-frame time must be reported
|
||||||
|
separately.
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
|
||||||
|
- Odin `dev-2026-05-nightly:ea5175d` (the version pinned by CI)
|
||||||
|
- SDL 3.4.12
|
||||||
|
- Linux x86-64
|
||||||
|
|
||||||
|
## Proposed change
|
||||||
|
|
||||||
|
Compile profiling binaries with optimization while retaining symbols:
|
||||||
|
|
||||||
|
```make
|
||||||
|
FLAME_ODIN_FLAGS ?= -debug -o:speed
|
||||||
|
|
||||||
|
flame-build:
|
||||||
|
odin build examples/$(FLAME_EXAMPLE) -collection:pkg=. \
|
||||||
|
-out:$(FLAME_BIN) $(FLAME_ODIN_FLAGS)
|
||||||
|
```
|
||||||
|
|
||||||
|
Keeping the flags configurable allows an explicitly unoptimized diagnostic run
|
||||||
|
without making it the performance default.
|
||||||
|
|
||||||
|
Consider applying an explicit optimization mode to performance-oriented example
|
||||||
|
runs as well. Plain `odin run` currently uses Odin's unoptimized default.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- `make flame-build` produces an optimized binary with debug symbols.
|
||||||
|
- `FLAME_ODIN_FLAGS` can override the default for diagnostic builds.
|
||||||
|
- `make check` and `make test` continue to pass.
|
||||||
|
- A new `crowd` profile records the exact compiler flags in its report or
|
||||||
|
accompanying documentation.
|
||||||
|
- Performance conclusions distinguish CPU `draw_sprite` cost from complete
|
||||||
|
frame/GPU submission cost.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Reduce repeated clip-space work in `draw_sprite`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
`draw_sprite` calls `to_clip` four times for an axis-aligned quad:
|
||||||
|
|
||||||
|
```odin
|
||||||
|
p0 := to_clip(x0_px, y0_px, sw, sh)
|
||||||
|
p1 := to_clip(x1_px, y0_px, sw, sh)
|
||||||
|
p2 := to_clip(x1_px, y1_px, sw, sh)
|
||||||
|
p3 := to_clip(x0_px, y1_px, sw, sh)
|
||||||
|
```
|
||||||
|
|
||||||
|
This repeats the same divisions and converts duplicate x/y coordinates. An
|
||||||
|
axis-aligned sprite has only two unique x values and two unique y values.
|
||||||
|
|
||||||
|
This is a measurable optimization, but it is low priority at the current
|
||||||
|
128-sprite limit because the absolute saving is small.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
The checked-in unoptimized profiles report `engine::to_clip` at 5.33% and 5.51%
|
||||||
|
self time. Those percentages are inflated by the unoptimized profiling build,
|
||||||
|
so the change was also measured with `-debug -o:speed`.
|
||||||
|
|
||||||
|
A temporary benchmark used the real baked toad metadata, changed sprite
|
||||||
|
position on every iteration, preallocated the queue, and performed two million
|
||||||
|
draws per mode over seven trials:
|
||||||
|
|
||||||
|
| Mode | Median time per draw |
|
||||||
|
| --- | ---: |
|
||||||
|
| Current `draw_sprite` | 25.128 ns |
|
||||||
|
| Precomputed clip scale and reused coordinates | 21.531 ns |
|
||||||
|
| Same math plus cached `Frame_Def` | 21.778 ns |
|
||||||
|
|
||||||
|
Simplifying the math improved isolated draw time by approximately **14.3%**.
|
||||||
|
Caching the resolved frame did not provide an additional benefit and should not
|
||||||
|
be included without new evidence.
|
||||||
|
|
||||||
|
At `MAX_SPRITES == 128`, the measured math saving is only about 0.46
|
||||||
|
microseconds per completely full frame. GPU/driver work dominated the
|
||||||
|
end-to-end benchmark, so this should follow the profiling and deterministic
|
||||||
|
benchmark improvements.
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
|
||||||
|
- Odin `dev-2026-05-nightly:ea5175d`
|
||||||
|
- Optimized with `-debug -o:speed`
|
||||||
|
- Linux x86-64
|
||||||
|
|
||||||
|
## Proposed change
|
||||||
|
|
||||||
|
Compute clip scaling once and construct corners from the unique coordinates:
|
||||||
|
|
||||||
|
```odin
|
||||||
|
sx := 2.0 / f32(app.swapchain_w)
|
||||||
|
sy := 2.0 / f32(app.swapchain_h)
|
||||||
|
|
||||||
|
left := x0_px * sx - 1
|
||||||
|
right := x1_px * sx - 1
|
||||||
|
top := 1 - y0_px * sy
|
||||||
|
bottom := 1 - y1_px * sy
|
||||||
|
|
||||||
|
p0 := Vec2{left, top}
|
||||||
|
p1 := Vec2{right, top}
|
||||||
|
p2 := Vec2{right, bottom}
|
||||||
|
p3 := Vec2{left, bottom}
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep `to_clip` for general callers and its existing tests; this change only
|
||||||
|
specializes quad construction inside `draw_sprite`.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- Existing sprite geometry, camera, UV, and batching tests pass.
|
||||||
|
- Add or extend a test that compares all four generated corners against
|
||||||
|
`to_clip` for representative viewport and sprite coordinates.
|
||||||
|
- Flipped and unflipped sprites produce identical vertices to the current code.
|
||||||
|
- An optimized deterministic benchmark shows at least a 10% improvement in
|
||||||
|
isolated `draw_sprite` time under comparable conditions.
|
||||||
|
- Do not add a per-sprite frame cache as part of this issue.
|
||||||
Reference in New Issue
Block a user