From 5e4758ce1639649ac1baa2c93222bfe63de9eac1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 9 Aug 2026 08:26:59 +0000 Subject: [PATCH] perf: add reproducible sprite benchmarks Co-authored-by: codegirl007 --- Makefile | 27 +++++ benchmarks/draw_sprite/main.odin | 102 ++++++++++++++++++ benchmarks/sprite_frame/main.odin | 168 ++++++++++++++++++++++++++++++ 3 files changed, 297 insertions(+) create mode 100644 benchmarks/draw_sprite/main.odin create mode 100644 benchmarks/sprite_frame/main.odin diff --git a/Makefile b/Makefile index f633a78..5619d81 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,6 @@ .PHONY: shaders-vulkan shaders-d3d12 shaders-metal shaders-all bake toad hello_sprite crowd camera_sandbox clips check test help .PHONY: flame flame-build flame-record flame-svg flame-report flame-tools +.PHONY: perf-draw perf-frame # Flamegraph profiling (needs: pacman -S perf). Example: make flame or make flame FLAME_EXAMPLE=toad FLAME_EXAMPLE ?= crowd @@ -12,6 +13,16 @@ FLAME_STAMP := $(shell date +%Y%m%d-%H%M%S) endif FLAME_PREFIX := $(FLAME_OUT_DIR)/$(FLAME_EXAMPLE)-$(FLAME_STAMP) +PERF_DRAW_ITERATIONS ?= 2000000 +PERF_DRAW_WARMUP ?= 10000 +PERF_DRAW_TRIALS ?= 7 +PERF_FRAME_SPRITES ?= 128 +PERF_FRAME_FRAMES ?= 400 +PERF_FRAME_WARMUP ?= 100 +PERF_FRAME_TRIALS ?= 10 +# 0=visible, 1=half offscreen, 2=alternating textures +PERF_FRAME_SCENARIO ?= 0 + help: @echo "Targets:" @echo " bake Bake all characters under content/characters/" @@ -31,6 +42,8 @@ help: @echo " flame-record perf record (play, then quit)" @echo " flame-svg Convert perf.data -> $(FLAME_PREFIX).svg/.jpg" @echo " flame-report Convert perf.data -> $(FLAME_PREFIX)-report.txt" + @echo " perf-draw Deterministic CPU draw benchmark" + @echo " perf-frame Deterministic SDL GPU frame benchmark (scenario 0/1/2)" bake: ./scripts/bake_all.sh @@ -57,6 +70,20 @@ check: odin check examples/camera_sandbox -collection:pkg=. odin check examples/clips -collection:pkg=. +perf-draw: + odin run benchmarks/draw_sprite -collection:pkg=. -debug -o:speed \ + -define:PERF_ITERATIONS=$(PERF_DRAW_ITERATIONS) \ + -define:PERF_WARMUP=$(PERF_DRAW_WARMUP) \ + -define:PERF_TRIALS=$(PERF_DRAW_TRIALS) + +perf-frame: + odin run benchmarks/sprite_frame -collection:pkg=. -debug -o:speed \ + -define:PERF_SPRITES=$(PERF_FRAME_SPRITES) \ + -define:PERF_FRAMES=$(PERF_FRAME_FRAMES) \ + -define:PERF_WARMUP_FRAMES=$(PERF_FRAME_WARMUP) \ + -define:PERF_TRIALS=$(PERF_FRAME_TRIALS) \ + -define:PERF_SCENARIO=$(PERF_FRAME_SCENARIO) + toad: odin run examples/toad -collection:pkg=. diff --git a/benchmarks/draw_sprite/main.odin b/benchmarks/draw_sprite/main.odin new file mode 100644 index 0000000..6d65d82 --- /dev/null +++ b/benchmarks/draw_sprite/main.odin @@ -0,0 +1,102 @@ +package main + +import "core:fmt" +import "core:os" +import eng "pkg:engine" +import sdl "vendor:sdl3" + +PERF_ITERATIONS :: #config(PERF_ITERATIONS, 2_000_000) +PERF_WARMUP :: #config(PERF_WARMUP, 10_000) +PERF_TRIALS :: #config(PERF_TRIALS, 7) + +@(private) +median :: proc(values: []f64) -> f64 { + for i in 1 ..< len(values) { + value := values[i] + j := i + for j > 0 { + if values[j - 1] <= value do break + values[j] = values[j - 1] + j -= 1 + } + values[j] = value + } + + middle := len(values) / 2 + if len(values) & 1 == 1 do return values[middle] + return (values[middle - 1] + values[middle]) / 2 +} + +@(private) +run_draws :: proc(app: ^eng.App, sprite: ^eng.Sprite, iterations: int) -> u64 { + clear(&app.draw_list) + start := sdl.GetTicksNS() + for i in 0 ..< iterations { + if len(app.draw_list) >= eng.MAX_SPRITES { + clear(&app.draw_list) + } + + // Vary an input so optimized builds cannot hoist the draw calculations. + sprite.position.x = f32(i & 1023) + eng.draw_sprite(app, sprite) + } + return sdl.GetTicksNS() - start +} + +main :: proc() { + #assert(PERF_ITERATIONS > 0) + #assert(PERF_WARMUP > 0) + #assert(PERF_TRIALS > 0) + + file_data, err := os.read_entire_file( + "assets_baked/characters/toad/toad.char.json", + context.allocator, + ) + if err != nil { + fmt.eprintfln("benchmark asset read failed: %v", err) + return + } + defer delete(file_data) + + def, ok := eng.parse_char_def(file_data) + if !ok do return + + data := eng.Character_Data { + def = def, + texture = cast(^sdl.GPUTexture)uintptr(1), + width = 1911, + height = 1526, + } + app := eng.App { + cmd = cast(^sdl.GPUCommandBuffer)uintptr(1), + swapchain_texture = cast(^sdl.GPUTexture)uintptr(2), + swapchain_w = 800, + swapchain_h = 600, + camera = eng.camera_default(), + draw_list = make( + [dynamic]eng.Queued_Sprite, + 0, + eng.MAX_SPRITES, + ), + } + defer delete(app.draw_list) + + sprite := eng.spawn_sprite(&data, {400, 400}, "walk", 4) + _ = run_draws(&app, &sprite, PERF_WARMUP) + + samples: [PERF_TRIALS]f64 + fmt.printfln( + "benchmark=draw_sprite iterations=%d warmup=%d trials=%d sprites_per_queue=%d", + PERF_ITERATIONS, + PERF_WARMUP, + PERF_TRIALS, + eng.MAX_SPRITES, + ) + for trial in 0 ..< PERF_TRIALS { + elapsed := run_draws(&app, &sprite, PERF_ITERATIONS) + samples[trial] = f64(elapsed) / f64(PERF_ITERATIONS) + fmt.printfln("trial=%d ns_per_draw=%.3f", trial + 1, samples[trial]) + } + + fmt.printfln("median_ns_per_draw=%.3f", median(samples[:])) +} diff --git a/benchmarks/sprite_frame/main.odin b/benchmarks/sprite_frame/main.odin new file mode 100644 index 0000000..01cf372 --- /dev/null +++ b/benchmarks/sprite_frame/main.odin @@ -0,0 +1,168 @@ +package main + +import "core:fmt" +import eng "pkg:engine" +import sdl "vendor:sdl3" + +PERF_SPRITES :: #config(PERF_SPRITES, 128) +PERF_FRAMES :: #config(PERF_FRAMES, 400) +PERF_WARMUP_FRAMES :: #config(PERF_WARMUP_FRAMES, 100) +PERF_TRIALS :: #config(PERF_TRIALS, 10) +PERF_SCENARIO :: #config(PERF_SCENARIO, 0) + +WINDOW_WIDTH :: 800 +WINDOW_HEIGHT :: 600 + +Scenario :: enum { + Visible, + Half_Offscreen, + Alternating_Textures, +} + +@(private) +median :: proc(values: []f64) -> f64 { + for i in 1 ..< len(values) { + value := values[i] + j := i + for j > 0 { + if values[j - 1] <= value do break + values[j] = values[j - 1] + j -= 1 + } + values[j] = value + } + + middle := len(values) / 2 + if len(values) & 1 == 1 do return values[middle] + return (values[middle - 1] + values[middle]) / 2 +} + +@(private) +scenario_name :: proc(scenario: Scenario) -> string { + switch scenario { + case .Visible: + return "visible" + case .Half_Offscreen: + return "half_offscreen" + case .Alternating_Textures: + return "alternating_textures" + } + return "unknown" +} + +@(private) +present_mode_name :: proc(app: ^eng.App) -> string { + if sdl.WindowSupportsGPUPresentMode(app.device, app.window, .IMMEDIATE) { + return "immediate" + } + if sdl.WindowSupportsGPUPresentMode(app.device, app.window, .MAILBOX) { + return "mailbox" + } + return "vsync" +} + +@(private) +draw_frame :: proc(app: ^eng.App, sprites: []eng.Sprite) { + for &sprite in sprites { + eng.update_sprite(&sprite, 1.0 / 60.0) + } + + eng.begin_frame(app) + for &sprite in sprites { + eng.draw_sprite(app, &sprite) + } + eng.end_frame(app) +} + +main :: proc() { + #assert(PERF_SPRITES > 0) + #assert(PERF_SPRITES <= eng.MAX_SPRITES) + #assert(PERF_FRAMES > 0) + #assert(PERF_WARMUP_FRAMES > 0) + #assert(PERF_TRIALS > 0) + #assert(PERF_SCENARIO >= 0 && PERF_SCENARIO <= 2) + + scenario := Scenario(PERF_SCENARIO) + + app: eng.App + if !eng.init(&app, "sprite frame benchmark", WINDOW_WIDTH, WINDOW_HEIGHT) { + return + } + defer eng.shutdown(&app) + + path := "assets_baked/characters/toad/toad.char.json" + data_a, ok := eng.load_character_data(&app, path) + if !ok do return + defer eng.destroy_character_data(&app, &data_a) + + data_b: eng.Character_Data + data_b, ok = eng.load_character_data(&app, path) + if !ok do return + defer eng.destroy_character_data(&app, &data_b) + + sprites: [PERF_SPRITES]eng.Sprite + for i in 0 ..< PERF_SPRITES { + data := &data_a + if scenario == .Alternating_Textures && (i & 1) == 1 { + data = &data_b + } + + position := eng.Vec2 { + f32(40 + (i % 16) * 48), + f32(120 + (i / 16) * 60), + } + if scenario == .Half_Offscreen && (i & 1) == 1 { + position = {-10_000, -10_000} + } + sprites[i] = eng.spawn_sprite(data, position, "walk", i % 17) + } + + for _ in 0 ..< PERF_WARMUP_FRAMES { + draw_frame(&app, sprites[:]) + } + if !sdl.WaitForGPUIdle(app.device) { + fmt.eprintfln("GPU wait failed after warm-up: %s", sdl.GetError()) + return + } + + fmt.printfln( + "benchmark=sprite_frame scenario=%s sprites=%d resolution=%dx%d frames=%d warmup=%d trials=%d backend=%v driver=%s present=%s", + scenario_name(scenario), + PERF_SPRITES, + WINDOW_WIDTH, + WINDOW_HEIGHT, + PERF_FRAMES, + PERF_WARMUP_FRAMES, + PERF_TRIALS, + app.shader.backend, + sdl.GetGPUDeviceDriver(app.device), + present_mode_name(&app), + ) + + samples: [PERF_TRIALS]f64 + for trial in 0 ..< PERF_TRIALS { + start := sdl.GetTicksNS() + for _ in 0 ..< PERF_FRAMES { + draw_frame(&app, sprites[:]) + } + if !sdl.WaitForGPUIdle(app.device) { + fmt.eprintfln("GPU wait failed after trial %d: %s", trial + 1, sdl.GetError()) + return + } + elapsed := sdl.GetTicksNS() - start + samples[trial] = f64(elapsed) / f64(PERF_FRAMES) / 1_000_000.0 + fmt.printfln( + "trial=%d ms_per_frame=%.3f fps=%.1f", + trial + 1, + samples[trial], + 1_000.0 / samples[trial], + ) + } + + median_ms := median(samples[:]) + fmt.printfln( + "median_ms_per_frame=%.3f median_fps=%.1f", + median_ms, + 1_000.0 / median_ms, + ) +}