fixing test, making profiling

This commit is contained in:
Michael Freno
2025-11-20 09:30:01 -05:00
parent 57eb52e70d
commit 32009185e9
37 changed files with 2587 additions and 1603 deletions

325
profiling/README.md Normal file
View File

@@ -0,0 +1,325 @@
# FlexLöve Performance Profiler
A comprehensive profiling system for stress testing and benchmarking FlexLöve's performance with the full Love2D runtime.
## Quick Start
1. **Run the profiler:**
```bash
love profiling/
```
2. **Select a profile** using arrow keys and press ENTER
3. **View real-time metrics** in the overlay (FPS, frame time, memory)
## Running Specific Profiles
Run a specific profile directly from the command line:
```bash
love profiling/ layout_stress_profile
love profiling/ animation_stress_profile
love profiling/ render_stress_profile
love profiling/ event_stress_profile
love profiling/ immediate_mode_profile
love profiling/ memory_profile
```
## Available Profiles
### Layout Stress Profile
Tests layout engine performance with large element hierarchies.
**Features:**
- Adjustable element count (100-5000)
- Multiple nesting levels
- Flexbox layout stress testing
- Dynamic element creation
**Controls:**
- `+` / `-` : Increase/decrease element count by 50
- `R` : Reset to default (100 elements)
- `ESC` : Return to menu
### Animation Stress Profile
Tests animation system performance with many concurrent animations.
**Features:**
- 100-1000 animated elements
- Multiple animation properties (position, size, color, opacity)
- Various easing functions
- Concurrent animations
**Controls:**
- `+` / `-` : Increase/decrease animation count by 50
- `SPACE` : Pause/resume all animations
- `R` : Reset animations
- `ESC` : Return to menu
### Render Stress Profile
Tests rendering performance with heavy draw operations.
**Features:**
- Thousands of drawable elements
- Rounded rectangles with various radii
- Text rendering stress
- Layering and overdraw scenarios
- Effects (blur, shadows)
**Controls:**
- `+` / `-` : Increase/decrease element count
- `1-5` : Toggle different render features
- `R` : Reset
- `ESC` : Return to menu
### Event Stress Profile
Tests event handling performance at scale.
**Features:**
- Many interactive elements (500+)
- Event propagation through deep hierarchies
- Hover and click event handling
- Hit testing performance
- Visual feedback on interactions
**Controls:**
- `+` / `-` : Increase/decrease interactive elements
- Move mouse to test hover performance
- Click elements to test event dispatch
- `R` : Reset
- `ESC` : Return to menu
### Immediate Mode Profile
Tests immediate mode where UI is recreated every frame.
**Features:**
- Full UI recreation each frame
- Performance comparison vs retained mode
- 50-300 element recreation
- State persistence across frames
- BeginFrame/EndFrame pattern
**Controls:**
- `+` / `-` : Increase/decrease element count
- `R` : Reset
- `ESC` : Return to menu
### Memory Profile
Tests memory usage patterns and garbage collection.
**Features:**
- Memory growth tracking
- GC frequency and pause time monitoring
- Element creation/destruction cycles
- ImageCache memory testing
- Memory leak detection
**Controls:**
- `SPACE` : Create/destroy element batch
- `G` : Force garbage collection
- `R` : Reset memory tracking
- `ESC` : Return to menu
## Performance Metrics
The profiler overlay displays:
- **FPS** : Current frames per second (color-coded: green=good, yellow=warning, red=critical)
- **Frame Time** : Current frame time in milliseconds
- **Avg Frame** : Average frame time across all frames
- **Min/Max** : Minimum and maximum frame times
- **P95/P99** : 95th and 99th percentile frame times
- **Memory** : Current memory usage in MB
- **Peak Memory** : Maximum memory usage recorded
- **Top Markers** : Custom timing markers (if used by profile)
## Keyboard Shortcuts
### Global Controls
- `ESC` : Return to menu (from profile) or quit (from menu)
- `R` : Reset current profile
- `F11` : Toggle fullscreen
### Menu Navigation
- `` / `` : Navigate profile list
- `ENTER` / `SPACE` : Select profile
## Creating Custom Profiles
Create a new file in `profiling/__profiles__/` following this template:
```lua
local FlexLove = require("FlexLove")
local profile = {}
function profile.init()
-- Initialize FlexLove and build your UI
FlexLove.init({
width = love.graphics.getWidth(),
height = love.graphics.getHeight(),
})
-- Build your test UI here
end
function profile.update(dt)
-- Update logic (animations, state changes, etc)
end
function profile.draw()
-- Draw your UI
-- The profiler overlay is drawn automatically
end
function profile.keypressed(key)
-- Handle keyboard input specific to your profile
end
function profile.resize(w, h)
-- Handle window resize
FlexLove.resize(w, h)
end
function profile.reset()
-- Reset profile to initial state
end
function profile.cleanup()
-- Clean up resources
end
return profile
```
The filename (without `.lua` extension) will be used as the profile name in the menu.
## Using PerformanceProfiler Directly
For custom timing markers in your profile:
```lua
local PerformanceProfiler = require("profiling.utils.PerformanceProfiler")
local profiler = PerformanceProfiler.new()
function profile.update(dt)
profiler:beginFrame()
-- Mark custom operation
profiler:markBegin("my_operation")
-- ... do something expensive ...
profiler:markEnd("my_operation")
profiler:endFrame()
end
function profile.draw()
-- Draw profiler overlay
profiler:draw(10, 10)
-- Export report
local report = profiler:getReport()
print("Average FPS:", report.fps.average)
print("My operation avg time:", report.markers.my_operation.average)
end
```
## Configuration
Edit `profiling/conf.lua` to adjust:
- Window size (default: 1280x720)
- VSync (default: off for uncapped FPS)
- MSAA (default: 4x)
- Stencil support (required for rounded rectangles)
## Interpreting Results
### Good Performance
- FPS: 60+ (displayed in green)
- Frame Time: < 13ms
- P99 Frame Time: < 16.67ms
### Warning Signs
- FPS: 45-60 (displayed in yellow)
- Frame Time: 13-16.67ms
- Frequent GC pauses
### Critical Issues
- FPS: < 45 (displayed in red)
- Frame Time: > 16.67ms
- Memory continuously growing
- Stuttering/frame drops
## Troubleshooting
### Profile fails to load
- Check Lua syntax errors in the profile file
- Ensure `profile.init()` function exists
- Verify FlexLove is initialized properly
### Low FPS in all profiles
- Disable VSync in conf.lua
- Check GPU drivers are up to date
- Try reducing element counts
- Monitor CPU/GPU usage externally
### Memory keeps growing
- Check for element leaks (not cleaning up)
- Verify event handlers are removed
- Test with Memory Profile to identify leaks
- Force GC with `G` key to see if memory is released
### Profiler overlay not showing
- Ensure PerformanceProfiler is initialized in profile
- Call `profiler:beginFrame()` and `profiler:endFrame()`
- Check overlay isn't being drawn off-screen
## Architecture
```
profiling/
├── conf.lua # Love2D configuration
├── main.lua # Main entry point and harness
├── __profiles__/ # Profile test files
│ ├── layout_stress_profile.lua
│ ├── animation_stress_profile.lua
│ ├── render_stress_profile.lua
│ ├── event_stress_profile.lua
│ ├── immediate_mode_profile.lua
│ └── memory_profile.lua
└── utils/
└── PerformanceProfiler.lua # Profiling utility module
```
## Tips for Profiling
1. **Start small**: Begin with low element counts and scale up
2. **Watch for drop-offs**: Note when FPS drops below 60
3. **Compare modes**: Test both immediate and retained modes
4. **Long runs**: Run profiles for 5+ minutes to catch memory leaks
5. **Use markers**: Add custom markers for specific operations
6. **Export data**: Use `profiler:exportJSON()` for detailed analysis
7. **Monitor externally**: Use OS tools to monitor CPU/GPU usage
## Performance Targets
FlexLöve should maintain 60 FPS with:
- 1000+ simple elements (retained mode)
- 200+ elements (immediate mode)
- 500+ concurrent animations
- 1000+ draw calls
- 500+ interactive elements
## Contributing
To add a new profile:
1. Create a new file in `__profiles__/` with `_profile.lua` suffix
2. Follow the profile template structure
3. Test thoroughly with various configurations
4. Document controls and features in this README
## License
Same license as FlexLöve (MIT)

View File

@@ -0,0 +1,220 @@
-- Animation Stress Profile
-- Tests animation system with many concurrent animations
local FlexLove = require("FlexLove")
local profile = {
animationCount = 100,
maxAnimations = 1000,
minAnimations = 10,
root = nil,
animations = {},
elements = {},
easingFunctions = {
"linear",
"easeInQuad",
"easeOutQuad",
"easeInOutQuad",
"easeInCubic",
"easeOutCubic",
"easeInOutCubic",
},
}
function profile.init()
FlexLove.init({
width = love.graphics.getWidth(),
height = love.graphics.getHeight(),
})
profile.buildLayout()
end
function profile.buildLayout()
profile.root = FlexLove.new({
width = "100%",
height = "100%",
backgroundColor = {0.05, 0.05, 0.1, 1},
flexDirection = "column",
overflow = "scroll",
padding = 20,
gap = 10,
})
-- Create animated elements container
local animationContainer = FlexLove.new({
width = "100%",
flexDirection = "row",
flexWrap = "wrap",
gap = 10,
marginBottom = 20,
})
profile.animations = {}
profile.elements = {}
for i = 1, profile.animationCount do
local hue = (i / profile.animationCount) * 360
local baseColor = {
0.3 + 0.5 * math.sin(hue * math.pi / 180),
0.3 + 0.5 * math.sin((hue + 120) * math.pi / 180),
0.3 + 0.5 * math.sin((hue + 240) * math.pi / 180),
1
}
-- Choose random easing function
local easingFunc = profile.easingFunctions[math.random(#profile.easingFunctions)]
local box = FlexLove.new({
width = 60,
height = 60,
backgroundColor = baseColor,
borderRadius = 8,
margin = 5,
})
-- Store base values for animation
box._baseY = box.y
box._baseOpacity = 1
box._baseBorderRadius = 8
box._baseColor = baseColor
-- Create animations manually since elements may not support automatic animation
local animDuration = 1 + math.random() * 2 -- 1-3 seconds
-- Y position animation
local yAnim = FlexLove.Animation.new({
duration = animDuration,
start = { offset = 0 },
final = { offset = 20 + math.random() * 40 },
easing = easingFunc,
}):yoyo(true):repeatCount(0) -- 0 = infinite loop
-- Opacity animation
local opacityAnim = FlexLove.Animation.new({
duration = animDuration * 0.8,
start = { opacity = 1 },
final = { opacity = 0.3 },
easing = easingFunc,
}):yoyo(true):repeatCount(0)
-- Border radius animation
local radiusAnim = FlexLove.Animation.new({
duration = animDuration * 1.2,
start = { borderRadius = 8 },
final = { borderRadius = 30 },
easing = easingFunc,
}):yoyo(true):repeatCount(0)
-- Store animations with element reference
table.insert(profile.animations, { element = box, animation = yAnim, property = "y" })
table.insert(profile.animations, { element = box, animation = opacityAnim, property = "opacity" })
table.insert(profile.animations, { element = box, animation = radiusAnim, property = "borderRadius" })
table.insert(profile.elements, box)
animationContainer:addChild(box)
end
profile.root:addChild(animationContainer)
-- Info panel
local infoPanel = FlexLove.new({
width = "100%",
padding = 15,
backgroundColor = {0.1, 0.1, 0.2, 0.9},
borderRadius = 8,
flexDirection = "column",
gap = 5,
})
infoPanel:addChild(FlexLove.new({
textContent = string.format("Animated Elements: %d (Press +/- to adjust)", profile.animationCount),
fontSize = 18,
color = {1, 1, 1, 1},
}))
infoPanel:addChild(FlexLove.new({
textContent = string.format("Active Animations: %d", #profile.animations),
fontSize = 14,
color = {0.8, 0.8, 0.8, 1},
}))
infoPanel:addChild(FlexLove.new({
textContent = "Animating: position, opacity, borderRadius",
fontSize = 14,
color = {0.8, 0.8, 0.8, 1},
}))
infoPanel:addChild(FlexLove.new({
textContent = string.format("Easing Functions: %d variations", #profile.easingFunctions),
fontSize = 14,
color = {0.8, 0.8, 0.8, 1},
}))
profile.root:addChild(infoPanel)
end
function profile.update(dt)
-- Update all animations and apply to elements
for _, animData in ipairs(profile.animations) do
animData.animation:update(dt)
local values = animData.animation:interpolate()
if animData.property == "y" and values.offset then
animData.element.y = (animData.element._baseY or animData.element.y) + values.offset
elseif animData.property == "opacity" and values.opacity then
animData.element.opacity = values.opacity
elseif animData.property == "borderRadius" and values.borderRadius then
animData.element.borderRadius = values.borderRadius
end
end
end
function profile.draw()
if profile.root then
profile.root:draw()
end
-- Overlay info
love.graphics.setColor(1, 1, 1, 1)
love.graphics.print("Animation Stress Test", 10, love.graphics.getHeight() - 100)
love.graphics.print(
string.format("Animations: %d | Range: %d-%d",
#profile.animations,
profile.minAnimations * 3,
profile.maxAnimations * 3
),
10,
love.graphics.getHeight() - 80
)
love.graphics.print("Press + to add 10 animated elements", 10, love.graphics.getHeight() - 60)
love.graphics.print("Press - to remove 10 animated elements", 10, love.graphics.getHeight() - 45)
end
function profile.keypressed(key)
if key == "=" or key == "+" then
profile.animationCount = math.min(profile.maxAnimations, profile.animationCount + 10)
profile.buildLayout()
elseif key == "-" or key == "_" then
profile.animationCount = math.max(profile.minAnimations, profile.animationCount - 10)
profile.buildLayout()
end
end
function profile.resize(w, h)
FlexLove.resize(w, h)
profile.buildLayout()
end
function profile.reset()
profile.animationCount = 100
profile.buildLayout()
end
function profile.cleanup()
profile.animations = {}
profile.elements = {}
profile.root = nil
end
return profile

View File

@@ -0,0 +1,219 @@
-- Event Stress Profile
-- Tests event handling at scale
local FlexLove = require("FlexLove")
local profile = {
elementCount = 200,
maxElements = 1000,
minElements = 50,
root = nil,
eventMetrics = {
hoverCount = 0,
clickCount = 0,
eventsThisFrame = 0,
},
metricsTimer = 0,
}
function profile.init()
FlexLove.init({
width = love.graphics.getWidth(),
height = love.graphics.getHeight(),
})
profile.buildLayout()
end
function profile.buildLayout()
profile.root = FlexLove.new({
width = "100%",
height = "100%",
backgroundColor = {0.05, 0.05, 0.1, 1},
flexDirection = "column",
overflow = "scroll",
padding = 20,
gap = 10,
})
-- Interactive elements container
local interactiveContainer = FlexLove.new({
width = "100%",
flexDirection = "row",
flexWrap = "wrap",
gap = 5,
marginBottom = 20,
})
for i = 1, profile.elementCount do
local hue = (i / profile.elementCount) * 360
local baseColor = {
0.3 + 0.5 * math.sin(hue * math.pi / 180),
0.3 + 0.5 * math.sin((hue + 120) * math.pi / 180),
0.3 + 0.5 * math.sin((hue + 240) * math.pi / 180),
1
}
-- Create nested interactive hierarchy
local outerBox = FlexLove.new({
width = 60,
height = 60,
backgroundColor = baseColor,
borderRadius = 8,
margin = 2,
justifyContent = "center",
alignItems = "center",
onEvent = function(element, event)
if event.type == "hover" then
profile.eventMetrics.hoverCount = profile.eventMetrics.hoverCount + 1
profile.eventMetrics.eventsThisFrame = profile.eventMetrics.eventsThisFrame + 1
element.backgroundColor = {
math.min(1, baseColor[1] * 1.3),
math.min(1, baseColor[2] * 1.3),
math.min(1, baseColor[3] * 1.3),
1
}
elseif event.type == "unhover" then
element.backgroundColor = baseColor
elseif event.type == "press" then
element.borderRadius = 15
elseif event.type == "release" then
profile.eventMetrics.clickCount = profile.eventMetrics.clickCount + 1
profile.eventMetrics.eventsThisFrame = profile.eventMetrics.eventsThisFrame + 1
element.borderRadius = 8
end
end,
})
-- Add nested button for event propagation testing
local innerBox = FlexLove.new({
width = "60%",
height = "60%",
backgroundColor = {baseColor[1] * 0.6, baseColor[2] * 0.6, baseColor[3] * 0.6, 1},
borderRadius = 5,
onEvent = function(element, event)
if event.type == "hover" then
profile.eventMetrics.eventsThisFrame = profile.eventMetrics.eventsThisFrame + 1
element.backgroundColor = {
math.min(1, baseColor[1] * 1.5),
math.min(1, baseColor[2] * 1.5),
math.min(1, baseColor[3] * 1.5),
1
}
elseif event.type == "unhover" then
element.backgroundColor = {baseColor[1] * 0.6, baseColor[2] * 0.6, baseColor[3] * 0.6, 1}
elseif event.type == "release" then
profile.eventMetrics.eventsThisFrame = profile.eventMetrics.eventsThisFrame + 1
end
end,
})
outerBox:addChild(innerBox)
interactiveContainer:addChild(outerBox)
end
profile.root:addChild(interactiveContainer)
-- Metrics panel
local metricsPanel = FlexLove.new({
width = "100%",
padding = 15,
backgroundColor = {0.1, 0.1, 0.2, 0.9},
borderRadius = 8,
flexDirection = "column",
gap = 5,
})
metricsPanel:addChild(FlexLove.new({
textContent = string.format("Interactive Elements: %d (Press +/- to adjust)", profile.elementCount),
fontSize = 18,
color = {1, 1, 1, 1},
}))
metricsPanel:addChild(FlexLove.new({
textContent = string.format("Total Hovers: %d", profile.eventMetrics.hoverCount),
fontSize = 14,
color = {0.8, 0.8, 0.8, 1},
}))
metricsPanel:addChild(FlexLove.new({
textContent = string.format("Total Clicks: %d", profile.eventMetrics.clickCount),
fontSize = 14,
color = {0.8, 0.8, 0.8, 1},
}))
metricsPanel:addChild(FlexLove.new({
textContent = string.format("Events/Frame: %d", profile.eventMetrics.eventsThisFrame),
fontSize = 14,
color = {0.8, 0.8, 0.8, 1},
}))
profile.root:addChild(metricsPanel)
end
function profile.update(dt)
-- Reset per-frame event counter
profile.metricsTimer = profile.metricsTimer + dt
if profile.metricsTimer >= 0.1 then -- Update metrics display every 100ms
profile.eventMetrics.eventsThisFrame = 0
profile.metricsTimer = 0
-- Rebuild to update metrics display
if profile.root then
profile.buildLayout()
end
end
end
function profile.draw()
if profile.root then
profile.root:draw()
end
-- Overlay info
love.graphics.setColor(1, 1, 1, 1)
love.graphics.print("Event Stress Test", 10, love.graphics.getHeight() - 120)
love.graphics.print(
string.format("Elements: %d | Range: %d-%d",
profile.elementCount,
profile.minElements,
profile.maxElements
),
10,
love.graphics.getHeight() - 100
)
love.graphics.print("Press + to add 25 interactive elements", 10, love.graphics.getHeight() - 80)
love.graphics.print("Press - to remove 25 interactive elements", 10, love.graphics.getHeight() - 65)
love.graphics.print("Hover and click elements to test event handling", 10, love.graphics.getHeight() - 50)
end
function profile.keypressed(key)
if key == "=" or key == "+" then
profile.elementCount = math.min(profile.maxElements, profile.elementCount + 25)
profile.buildLayout()
elseif key == "-" or key == "_" then
profile.elementCount = math.max(profile.minElements, profile.elementCount - 25)
profile.buildLayout()
end
end
function profile.resize(w, h)
FlexLove.resize(w, h)
profile.buildLayout()
end
function profile.reset()
profile.elementCount = 200
profile.eventMetrics = {
hoverCount = 0,
clickCount = 0,
eventsThisFrame = 0,
}
profile.metricsTimer = 0
profile.buildLayout()
end
function profile.cleanup()
profile.root = nil
end
return profile

View File

@@ -0,0 +1,189 @@
-- Immediate Mode Profile
-- Tests immediate mode where UI recreates each frame
local FlexLove = require("FlexLove")
local profile = {
elementCount = 50,
maxElements = 300,
minElements = 10,
frameCount = 0,
}
function profile.init()
FlexLove.init({
width = love.graphics.getWidth(),
height = love.graphics.getHeight(),
immediateMode = true,
})
end
function profile.buildUI()
-- In immediate mode, we recreate the UI every frame
local root = FlexLove.new({
id = "root", -- ID required for state persistence
width = "100%",
height = "100%",
backgroundColor = {0.05, 0.05, 0.1, 1},
flexDirection = "column",
overflow = "scroll",
padding = 20,
gap = 10,
})
-- Dynamic content container
local content = FlexLove.new({
id = "content",
width = "100%",
flexDirection = "row",
flexWrap = "wrap",
gap = 5,
marginBottom = 20,
})
for i = 1, profile.elementCount do
local hue = (i / profile.elementCount) * 360
local baseColor = {
0.3 + 0.5 * math.sin(hue * math.pi / 180),
0.3 + 0.5 * math.sin((hue + 120) * math.pi / 180),
0.3 + 0.5 * math.sin((hue + 240) * math.pi / 180),
1
}
-- Each element needs a unique ID for state persistence
local box = FlexLove.new({
id = string.format("box_%d", i),
width = 60,
height = 60,
backgroundColor = baseColor,
borderRadius = 8,
margin = 2,
onEvent = function(element, event)
if event.type == "hover" then
element.backgroundColor = {
math.min(1, baseColor[1] * 1.3),
math.min(1, baseColor[2] * 1.3),
math.min(1, baseColor[3] * 1.3),
1
}
elseif event.type == "unhover" then
element.backgroundColor = baseColor
elseif event.type == "press" then
element.borderRadius = 15
elseif event.type == "release" then
element.borderRadius = 8
end
end,
})
content:addChild(box)
end
root:addChild(content)
-- Info panel (also recreated each frame)
local infoPanel = FlexLove.new({
id = "infoPanel",
width = "100%",
padding = 15,
backgroundColor = {0.1, 0.1, 0.2, 0.9},
borderRadius = 8,
flexDirection = "column",
gap = 5,
})
infoPanel:addChild(FlexLove.new({
id = "info_title",
textContent = string.format("Immediate Mode: %d Elements", profile.elementCount),
fontSize = 18,
color = {1, 1, 1, 1},
}))
infoPanel:addChild(FlexLove.new({
id = "info_frame",
textContent = string.format("Frame: %d", profile.frameCount),
fontSize = 14,
color = {0.8, 0.8, 0.8, 1},
}))
infoPanel:addChild(FlexLove.new({
id = "info_states",
textContent = string.format("Active States: %d", FlexLove.getStateCount()),
fontSize = 14,
color = {0.8, 0.8, 0.8, 1},
}))
infoPanel:addChild(FlexLove.new({
id = "info_help",
textContent = "Press +/- to adjust element count",
fontSize = 12,
color = {0.7, 0.7, 0.7, 1},
}))
root:addChild(infoPanel)
return root
end
function profile.update(dt)
profile.frameCount = profile.frameCount + 1
end
function profile.draw()
-- Immediate mode: rebuild UI every frame
FlexLove.beginFrame()
local root = profile.buildUI()
FlexLove.endFrame()
-- Draw the UI
if root then
root:draw()
end
-- Overlay info
love.graphics.setColor(1, 1, 1, 1)
love.graphics.print("Immediate Mode Stress Test", 10, love.graphics.getHeight() - 120)
love.graphics.print(
string.format("Elements: %d | Range: %d-%d",
profile.elementCount,
profile.minElements,
profile.maxElements
),
10,
love.graphics.getHeight() - 100
)
love.graphics.print(
string.format("Frames: %d | States: %d",
profile.frameCount,
FlexLove.getStateCount()
),
10,
love.graphics.getHeight() - 80
)
love.graphics.print("Press + to add 10 elements", 10, love.graphics.getHeight() - 60)
love.graphics.print("Press - to remove 10 elements", 10, love.graphics.getHeight() - 45)
end
function profile.keypressed(key)
if key == "=" or key == "+" then
profile.elementCount = math.min(profile.maxElements, profile.elementCount + 10)
elseif key == "-" or key == "_" then
profile.elementCount = math.max(profile.minElements, profile.elementCount - 10)
end
end
function profile.resize(w, h)
FlexLove.resize(w, h)
end
function profile.reset()
profile.elementCount = 50
profile.frameCount = 0
FlexLove.clearAllStates()
end
function profile.cleanup()
FlexLove.clearAllStates()
end
return profile

View File

@@ -0,0 +1,149 @@
-- Layout Stress Profile
-- Tests layout engine performance with large element hierarchies
local FlexLove = require("FlexLove")
local profile = {
elementCount = 100,
maxElements = 5000,
nestingDepth = 5,
root = nil,
}
function profile.init()
FlexLove.init({
width = love.graphics.getWidth(),
height = love.graphics.getHeight(),
})
profile.buildLayout()
end
function profile.buildLayout()
local width = love.graphics.getWidth()
local height = love.graphics.getHeight()
profile.root = FlexLove.new({
width = "100%",
height = "100%",
backgroundColor = {0.05, 0.05, 0.1, 1},
flexDirection = "column",
overflow = "scroll",
padding = 20,
gap = 10,
})
local elementsPerRow = math.floor(math.sqrt(profile.elementCount))
local rows = math.ceil(profile.elementCount / elementsPerRow)
for r = 1, rows do
local row = FlexLove.new({
flexDirection = "row",
gap = 10,
flexWrap = "wrap",
})
local itemsInRow = math.min(elementsPerRow, profile.elementCount - (r - 1) * elementsPerRow)
for c = 1, itemsInRow do
local hue = ((r - 1) * elementsPerRow + c) / profile.elementCount
local color = {
0.3 + 0.5 * math.sin(hue * math.pi * 2),
0.3 + 0.5 * math.sin((hue + 0.33) * math.pi * 2),
0.3 + 0.5 * math.sin((hue + 0.66) * math.pi * 2),
1
}
local box = FlexLove.new({
width = 80,
height = 80,
backgroundColor = color,
borderRadius = 8,
justifyContent = "center",
alignItems = "center",
})
local nested = box
for d = 1, math.min(profile.nestingDepth, 3) do
local innerBox = FlexLove.new({
width = "80%",
height = "80%",
backgroundColor = {color[1] * 0.8, color[2] * 0.8, color[3] * 0.8, color[4]},
borderRadius = 6,
justifyContent = "center",
alignItems = "center",
})
nested:addChild(innerBox)
nested = innerBox
end
row:addChild(box)
end
profile.root:addChild(row)
end
local infoPanel = FlexLove.new({
width = "100%",
padding = 15,
backgroundColor = {0.1, 0.1, 0.2, 0.9},
borderRadius = 8,
marginTop = 20,
flexDirection = "column",
gap = 5,
})
infoPanel:addChild(FlexLove.new({
textContent = string.format("Elements: %d (Press +/- to adjust)", profile.elementCount),
fontSize = 18,
color = {1, 1, 1, 1},
}))
infoPanel:addChild(FlexLove.new({
textContent = string.format("Nesting Depth: %d", profile.nestingDepth),
fontSize = 14,
color = {0.8, 0.8, 0.8, 1},
}))
profile.root:addChild(infoPanel)
end
function profile.update(dt)
end
function profile.draw()
if profile.root then
profile.root:draw()
end
love.graphics.setColor(1, 1, 1, 1)
love.graphics.print("Layout Stress Test", 10, love.graphics.getHeight() - 100)
love.graphics.print(string.format("Elements: %d | Max: %d", profile.elementCount, profile.maxElements), 10, love.graphics.getHeight() - 80)
love.graphics.print("Press + to add 50 elements", 10, love.graphics.getHeight() - 60)
love.graphics.print("Press - to remove 50 elements", 10, love.graphics.getHeight() - 45)
end
function profile.keypressed(key)
if key == "=" or key == "+" then
profile.elementCount = math.min(profile.maxElements, profile.elementCount + 50)
profile.buildLayout()
elseif key == "-" or key == "_" then
profile.elementCount = math.max(10, profile.elementCount - 50)
profile.buildLayout()
end
end
function profile.resize(w, h)
FlexLove.resize(w, h)
profile.buildLayout()
end
function profile.reset()
profile.elementCount = 100
profile.buildLayout()
end
function profile.cleanup()
profile.root = nil
end
return profile

View File

@@ -0,0 +1,239 @@
-- Memory Profile
-- Tests memory usage and GC patterns
local FlexLove = require("FlexLove")
local profile = {
elementCount = 100,
maxElements = 500,
minElements = 50,
root = nil,
memoryStats = {
startMemory = 0,
currentMemory = 0,
peakMemory = 0,
gcCount = 0,
lastGCTime = 0,
},
updateTimer = 0,
createDestroyTimer = 0,
createDestroyInterval = 2, -- seconds between create/destroy cycles
}
function profile.init()
FlexLove.init({
width = love.graphics.getWidth(),
height = love.graphics.getHeight(),
gcStrategy = "manual", -- Manual GC for testing
})
-- Record starting memory
collectgarbage("collect")
collectgarbage("collect")
profile.memoryStats.startMemory = collectgarbage("count") / 1024 -- MB
profile.memoryStats.peakMemory = profile.memoryStats.startMemory
profile.buildLayout()
end
function profile.buildLayout()
-- Clear existing root
if profile.root then
profile.root = nil
end
profile.root = FlexLove.new({
width = "100%",
height = "100%",
backgroundColor = {0.05, 0.05, 0.1, 1},
flexDirection = "column",
overflow = "scroll",
padding = 20,
gap = 10,
})
-- Create elements container
local elementsContainer = FlexLove.new({
width = "100%",
flexDirection = "row",
flexWrap = "wrap",
gap = 5,
marginBottom = 20,
})
for i = 1, profile.elementCount do
local hue = (i / profile.elementCount) * 360
local color = {
0.3 + 0.5 * math.sin(hue * math.pi / 180),
0.3 + 0.5 * math.sin((hue + 120) * math.pi / 180),
0.3 + 0.5 * math.sin((hue + 240) * math.pi / 180),
1
}
local box = FlexLove.new({
width = 50,
height = 50,
backgroundColor = color,
borderRadius = 8,
margin = 2,
})
elementsContainer:addChild(box)
end
profile.root:addChild(elementsContainer)
-- Memory stats panel
local statsPanel = FlexLove.new({
width = "100%",
padding = 15,
backgroundColor = {0.1, 0.1, 0.2, 0.9},
borderRadius = 8,
flexDirection = "column",
gap = 5,
})
local currentMem = collectgarbage("count") / 1024
local memGrowth = currentMem - profile.memoryStats.startMemory
statsPanel:addChild(FlexLove.new({
textContent = string.format("Memory Profile | Elements: %d", profile.elementCount),
fontSize = 18,
color = {1, 1, 1, 1},
}))
statsPanel:addChild(FlexLove.new({
textContent = string.format("Current: %.2f MB | Peak: %.2f MB", currentMem, profile.memoryStats.peakMemory),
fontSize = 14,
color = {0.8, 0.8, 0.8, 1},
}))
statsPanel:addChild(FlexLove.new({
textContent = string.format("Growth: %.2f MB | GC Count: %d", memGrowth, profile.memoryStats.gcCount),
fontSize = 14,
color = {0.8, 0.8, 0.8, 1},
}))
statsPanel:addChild(FlexLove.new({
textContent = "Press G to force GC | Press +/- to adjust elements",
fontSize = 12,
color = {0.7, 0.7, 0.7, 1},
}))
profile.root:addChild(statsPanel)
end
function profile.update(dt)
profile.updateTimer = profile.updateTimer + dt
profile.createDestroyTimer = profile.createDestroyTimer + dt
-- Update memory stats every 0.5 seconds
if profile.updateTimer >= 0.5 then
profile.updateTimer = 0
profile.memoryStats.currentMemory = collectgarbage("count") / 1024
if profile.memoryStats.currentMemory > profile.memoryStats.peakMemory then
profile.memoryStats.peakMemory = profile.memoryStats.currentMemory
end
-- Rebuild to update stats display
profile.buildLayout()
end
-- Automatically create and destroy elements to stress GC
if profile.createDestroyTimer >= profile.createDestroyInterval then
profile.createDestroyTimer = 0
-- Destroy old elements
profile.root = nil
-- Create new elements
profile.buildLayout()
end
end
function profile.draw()
if profile.root then
profile.root:draw()
end
-- Overlay info
love.graphics.setColor(1, 1, 1, 1)
love.graphics.print("Memory Stress Test", 10, love.graphics.getHeight() - 140)
love.graphics.print(
string.format("Elements: %d | Range: %d-%d",
profile.elementCount,
profile.minElements,
profile.maxElements
),
10,
love.graphics.getHeight() - 120
)
love.graphics.print(
string.format("Memory: %.2f MB | Peak: %.2f MB",
collectgarbage("count") / 1024,
profile.memoryStats.peakMemory
),
10,
love.graphics.getHeight() - 100
)
love.graphics.print(
string.format("GC Count: %d | Strategy: manual",
profile.memoryStats.gcCount
),
10,
love.graphics.getHeight() - 80
)
love.graphics.print("Press G to force garbage collection", 10, love.graphics.getHeight() - 60)
love.graphics.print("Press + to add 25 elements", 10, love.graphics.getHeight() - 45)
love.graphics.print("Press - to remove 25 elements", 10, love.graphics.getHeight() - 30)
end
function profile.keypressed(key)
if key == "=" or key == "+" then
profile.elementCount = math.min(profile.maxElements, profile.elementCount + 25)
profile.buildLayout()
elseif key == "-" or key == "_" then
profile.elementCount = math.max(profile.minElements, profile.elementCount - 25)
profile.buildLayout()
elseif key == "g" then
-- Force garbage collection
local beforeGC = collectgarbage("count") / 1024
collectgarbage("collect")
collectgarbage("collect") -- Run twice for thorough cleanup
local afterGC = collectgarbage("count") / 1024
profile.memoryStats.gcCount = profile.memoryStats.gcCount + 1
profile.memoryStats.lastGCTime = love.timer.getTime()
print(string.format("Manual GC: %.2f MB -> %.2f MB (freed %.2f MB)",
beforeGC, afterGC, beforeGC - afterGC))
profile.buildLayout() -- Update stats display
end
end
function profile.resize(w, h)
FlexLove.resize(w, h)
profile.buildLayout()
end
function profile.reset()
profile.elementCount = 100
collectgarbage("collect")
collectgarbage("collect")
profile.memoryStats.startMemory = collectgarbage("count") / 1024
profile.memoryStats.peakMemory = profile.memoryStats.startMemory
profile.memoryStats.gcCount = 0
profile.memoryStats.lastGCTime = 0
profile.updateTimer = 0
profile.createDestroyTimer = 0
profile.buildLayout()
end
function profile.cleanup()
profile.root = nil
collectgarbage("collect")
collectgarbage("collect")
end
return profile

View File

@@ -0,0 +1,187 @@
-- Render Stress Profile
-- Tests rendering with heavy draw operations
local FlexLove = require("FlexLove")
local profile = {
elementCount = 200,
maxElements = 2000,
minElements = 50,
root = nil,
showRounded = true,
showText = true,
showLayering = true,
}
function profile.init()
FlexLove.init({
width = love.graphics.getWidth(),
height = love.graphics.getHeight(),
})
profile.buildLayout()
end
function profile.buildLayout()
profile.root = FlexLove.new({
width = "100%",
height = "100%",
backgroundColor = {0.05, 0.05, 0.1, 1},
flexDirection = "column",
overflow = "scroll",
padding = 20,
gap = 10,
})
-- Render container
local renderContainer = FlexLove.new({
width = "100%",
flexDirection = "row",
flexWrap = "wrap",
gap = 5,
marginBottom = 20,
})
for i = 1, profile.elementCount do
local hue = (i / profile.elementCount) * 360
local color = {
0.3 + 0.5 * math.sin(hue * math.pi / 180),
0.3 + 0.5 * math.sin((hue + 120) * math.pi / 180),
0.3 + 0.5 * math.sin((hue + 240) * math.pi / 180),
1
}
local box = FlexLove.new({
width = 50,
height = 50,
backgroundColor = color,
borderRadius = profile.showRounded and (5 + math.random(20)) or 0,
margin = 2,
})
-- Add text rendering if enabled
if profile.showText then
box:addChild(FlexLove.new({
textContent = tostring(i),
fontSize = 12,
color = {1, 1, 1, 0.8},
}))
end
-- Add layering (nested elements) if enabled
if profile.showLayering and i % 3 == 0 then
local innerBox = FlexLove.new({
width = "80%",
height = "80%",
backgroundColor = {color[1] * 0.5, color[2] * 0.5, color[3] * 0.5, 0.7},
borderRadius = profile.showRounded and 8 or 0,
justifyContent = "center",
alignItems = "center",
})
box:addChild(innerBox)
end
renderContainer:addChild(box)
end
profile.root:addChild(renderContainer)
-- Controls panel
local controlsPanel = FlexLove.new({
width = "100%",
padding = 15,
backgroundColor = {0.1, 0.1, 0.2, 0.9},
borderRadius = 8,
flexDirection = "column",
gap = 8,
})
controlsPanel:addChild(FlexLove.new({
textContent = string.format("Render Elements: %d (Press +/- to adjust)", profile.elementCount),
fontSize = 18,
color = {1, 1, 1, 1},
}))
controlsPanel:addChild(FlexLove.new({
textContent = string.format("[R] Rounded Rectangles: %s", profile.showRounded and "ON" or "OFF"),
fontSize = 14,
color = {0.8, 0.8, 0.8, 1},
}))
controlsPanel:addChild(FlexLove.new({
textContent = string.format("[T] Text Rendering: %s", profile.showText and "ON" or "OFF"),
fontSize = 14,
color = {0.8, 0.8, 0.8, 1},
}))
controlsPanel:addChild(FlexLove.new({
textContent = string.format("[L] Layering/Overdraw: %s", profile.showLayering and "ON" or "OFF"),
fontSize = 14,
color = {0.8, 0.8, 0.8, 1},
}))
profile.root:addChild(controlsPanel)
end
function profile.update(dt)
end
function profile.draw()
if profile.root then
profile.root:draw()
end
-- Overlay info
love.graphics.setColor(1, 1, 1, 1)
love.graphics.print("Render Stress Test", 10, love.graphics.getHeight() - 120)
love.graphics.print(
string.format("Elements: %d | Range: %d-%d",
profile.elementCount,
profile.minElements,
profile.maxElements
),
10,
love.graphics.getHeight() - 100
)
love.graphics.print("Press + to add 50 elements", 10, love.graphics.getHeight() - 80)
love.graphics.print("Press - to remove 50 elements", 10, love.graphics.getHeight() - 65)
love.graphics.print("Press R/T/L to toggle features", 10, love.graphics.getHeight() - 50)
end
function profile.keypressed(key)
if key == "=" or key == "+" then
profile.elementCount = math.min(profile.maxElements, profile.elementCount + 50)
profile.buildLayout()
elseif key == "-" or key == "_" then
profile.elementCount = math.max(profile.minElements, profile.elementCount - 50)
profile.buildLayout()
elseif key == "r" then
profile.showRounded = not profile.showRounded
profile.buildLayout()
elseif key == "t" then
profile.showText = not profile.showText
profile.buildLayout()
elseif key == "l" then
profile.showLayering = not profile.showLayering
profile.buildLayout()
end
end
function profile.resize(w, h)
FlexLove.resize(w, h)
profile.buildLayout()
end
function profile.reset()
profile.elementCount = 200
profile.showRounded = true
profile.showText = true
profile.showLayering = true
profile.buildLayout()
end
function profile.cleanup()
profile.root = nil
end
return profile

46
profiling/conf.lua Normal file
View File

@@ -0,0 +1,46 @@
---@diagnostic disable: lowercase-global
function love.conf(t)
t.identity = "flexlove-profiler"
t.version = "11.5"
t.console = true
-- Window configuration
t.window.title = "FlexLöve Profiler"
t.window.width = 1280
t.window.height = 720
t.window.borderless = false
t.window.resizable = true
t.window.minwidth = 800
t.window.minheight = 600
t.window.fullscreen = false
t.window.fullscreentype = "desktop"
t.window.vsync = 0 -- Disable VSync for uncapped FPS testing
t.window.msaa = 4
t.window.depth = nil
t.window.stencil = true -- Required for rounded rectangles
t.window.display = 1
t.window.highdpi = true
t.window.usedpiscale = true
t.window.x = nil
t.window.y = nil
-- Enable required modules
t.modules.audio = false -- Not needed for UI profiling
t.modules.data = true
t.modules.event = true
t.modules.font = true
t.modules.graphics = true
t.modules.image = true
t.modules.joystick = false -- Not needed
t.modules.keyboard = true
t.modules.math = true
t.modules.mouse = true
t.modules.physics = false -- Not needed
t.modules.sound = false -- Not needed
t.modules.system = true
t.modules.thread = false
t.modules.timer = true -- Essential for profiling
t.modules.touch = true
t.modules.video = false -- Not needed
t.modules.window = true
end

336
profiling/main.lua Normal file
View File

@@ -0,0 +1,336 @@
-- FlexLöve Profiler - Main Entry Point
-- Load FlexLöve from parent directory
package.path = package.path .. ";../?.lua;../?/init.lua"
local FlexLove = require("FlexLove")
local PerformanceProfiler = require("profiling.utils.PerformanceProfiler")
local state = {
mode = "menu", -- "menu" or "profile"
currentProfile = nil,
profiler = nil,
profiles = {},
selectedIndex = 1,
ui = nil,
error = nil,
}
---@return table
local function discoverProfiles()
local profiles = {}
local files = love.filesystem.getDirectoryItems("__profiles__")
for _, file in ipairs(files) do
if file:match("%.lua$") then
local name = file:gsub("%.lua$", "")
table.insert(profiles, {
name = name,
displayName = name:gsub("_", " "):gsub("(%a)(%w*)", function(a, b) return a:upper() .. b end),
path = "__profiles__/" .. file,
})
end
end
table.sort(profiles, function(a, b) return a.name < b.name end)
return profiles
end
---@param profileInfo table
local function loadProfile(profileInfo)
state.error = nil
local success, profile = pcall(function()
return require("profiling.__profiles__." .. profileInfo.name)
end)
if not success then
state.error = "Failed to load profile: " .. tostring(profile)
return false
end
if type(profile.init) ~= "function" then
state.error = "Profile missing init() function"
return false
end
state.currentProfile = profile
state.profiler = PerformanceProfiler.new()
state.mode = "profile"
success, state.error = pcall(function()
profile.init()
end)
if not success then
state.error = "Profile init failed: " .. tostring(state.error)
state.currentProfile = nil
state.mode = "menu"
return false
end
return true
end
local function returnToMenu()
if state.currentProfile and type(state.currentProfile.cleanup) == "function" then
pcall(function() state.currentProfile.cleanup() end)
end
state.currentProfile = nil
state.profiler = nil
state.mode = "menu"
collectgarbage("collect")
end
local function buildMenu()
FlexLove.beginFrame()
local root = FlexLove.new({
width = "100%",
height = "100%",
backgroundColor = {0.1, 0.1, 0.15, 1},
flexDirection = "column",
justifyContent = "flex-start",
alignItems = "center",
padding = 40,
})
root:addChild(FlexLove.new({
flexDirection = "column",
alignItems = "center",
gap = 30,
children = {
FlexLove.new({
width = 600,
height = 80,
backgroundColor = {0.15, 0.15, 0.25, 1},
borderRadius = 10,
justifyContent = "center",
alignItems = "center",
children = {
FlexLove.new({
textContent = "FlexLöve Performance Profiler",
fontSize = 32,
color = {0.3, 0.8, 1, 1},
})
}
}),
FlexLove.new({
textContent = "Select a profile to run:",
fontSize = 20,
color = {0.8, 0.8, 0.8, 1},
}),
FlexLove.new({
width = 600,
flexDirection = "column",
gap = 10,
children = (function()
local items = {}
for i, profile in ipairs(state.profiles) do
local isSelected = i == state.selectedIndex
table.insert(items, FlexLove.new({
width = "100%",
height = 50,
backgroundColor = isSelected and {0.2, 0.4, 0.8, 1} or {0.15, 0.15, 0.25, 1},
borderRadius = 8,
justifyContent = "flex-start",
alignItems = "center",
padding = 15,
cursor = "pointer",
onClick = function()
state.selectedIndex = i
loadProfile(profile)
end,
onHover = function(element)
if not isSelected then
element.backgroundColor = {0.2, 0.2, 0.35, 1}
end
end,
onHoverEnd = function(element)
if not isSelected then
element.backgroundColor = {0.15, 0.15, 0.25, 1}
end
end,
children = {
FlexLove.new({
textContent = profile.displayName,
fontSize = 18,
color = isSelected and {1, 1, 1, 1} or {0.8, 0.8, 0.8, 1},
})
}
}))
end
return items
end)()
}),
FlexLove.new({
textContent = "Use ↑/↓ to select, ENTER to run, ESC to quit",
fontSize = 14,
color = {0.5, 0.5, 0.5, 1},
marginTop = 20,
}),
}
}))
if state.error then
root:addChild(FlexLove.new({
width = 600,
padding = 15,
backgroundColor = {0.8, 0.2, 0.2, 1},
borderRadius = 8,
marginTop = 20,
children = {
FlexLove.new({
textContent = "Error: " .. state.error,
fontSize = 14,
color = {1, 1, 1, 1},
})
}
}))
end
FlexLove.endFrame()
end
function love.load(args)
FlexLove.init({
width = love.graphics.getWidth(),
height = love.graphics.getHeight(),
})
state.profiles = discoverProfiles()
if #args > 0 then
local profileName = args[1]
for _, profile in ipairs(state.profiles) do
if profile.name == profileName then
loadProfile(profile)
return
end
end
print("Profile not found: " .. profileName)
end
end
function love.update(dt)
if state.mode == "menu" then
FlexLove.update(dt)
elseif state.mode == "profile" and state.currentProfile then
if state.profiler then
state.profiler:beginFrame()
end
if type(state.currentProfile.update) == "function" then
local success, err = pcall(function()
state.currentProfile.update(dt)
end)
if not success then
state.error = "Profile update error: " .. tostring(err)
returnToMenu()
end
end
if state.profiler then
state.profiler:endFrame()
end
end
end
function love.draw()
if state.mode == "menu" then
buildMenu()
FlexLove.draw()
elseif state.mode == "profile" and state.currentProfile then
if type(state.currentProfile.draw) == "function" then
local success, err = pcall(function()
state.currentProfile.draw()
end)
if not success then
state.error = "Profile draw error: " .. tostring(err)
returnToMenu()
return
end
end
if state.profiler then
state.profiler:draw(10, 10)
end
love.graphics.setColor(1, 1, 1, 1)
love.graphics.print("Press R to reset | ESC to menu | F11 fullscreen", 10, love.graphics.getHeight() - 25)
end
end
function love.keypressed(key)
if state.mode == "menu" then
if key == "escape" then
love.event.quit()
elseif key == "up" then
state.selectedIndex = math.max(1, state.selectedIndex - 1)
elseif key == "down" then
state.selectedIndex = math.min(#state.profiles, state.selectedIndex + 1)
elseif key == "return" or key == "space" then
if state.profiles[state.selectedIndex] then
loadProfile(state.profiles[state.selectedIndex])
end
end
elseif state.mode == "profile" then
if key == "escape" then
returnToMenu()
elseif key == "r" then
if state.profiler then
state.profiler:reset()
end
if state.currentProfile and type(state.currentProfile.reset) == "function" then
pcall(function() state.currentProfile.reset() end)
end
elseif key == "f11" then
love.window.setFullscreen(not love.window.getFullscreen())
end
if state.currentProfile and type(state.currentProfile.keypressed) == "function" then
pcall(function() state.currentProfile.keypressed(key) end)
end
end
end
function love.mousepressed(x, y, button)
if state.mode == "profile" and state.currentProfile then
if type(state.currentProfile.mousepressed) == "function" then
pcall(function() state.currentProfile.mousepressed(x, y, button) end)
end
end
end
function love.mousereleased(x, y, button)
if state.mode == "profile" and state.currentProfile then
if type(state.currentProfile.mousereleased) == "function" then
pcall(function() state.currentProfile.mousereleased(x, y, button) end)
end
end
end
function love.mousemoved(x, y, dx, dy)
if state.mode == "profile" and state.currentProfile then
if type(state.currentProfile.mousemoved) == "function" then
pcall(function() state.currentProfile.mousemoved(x, y, dx, dy) end)
end
end
end
function love.resize(w, h)
FlexLove.resize(w, h)
if state.mode == "profile" and state.currentProfile then
if type(state.currentProfile.resize) == "function" then
pcall(function() state.currentProfile.resize(w, h) end)
end
end
end
function love.quit()
if state.currentProfile and type(state.currentProfile.cleanup) == "function" then
pcall(function() state.currentProfile.cleanup() end)
end
end

View File

@@ -0,0 +1,421 @@
---@class PerformanceProfiler
---@field _frameCount number
---@field _startTime number
---@field _frameTimes table
---@field _fpsHistory table
---@field _memoryHistory table
---@field _customMetrics table
---@field _markers table
---@field _currentFrameStart number?
---@field _maxHistorySize number
---@field _lastGcCount number
local PerformanceProfiler = {}
PerformanceProfiler.__index = PerformanceProfiler
---@param config {maxHistorySize: number?}?
---@return PerformanceProfiler
function PerformanceProfiler.new(config)
local self = setmetatable({}, PerformanceProfiler)
config = config or {}
self._maxHistorySize = config.maxHistorySize or 300
self._frameCount = 0
self._startTime = love.timer.getTime()
self._frameTimes = {}
self._fpsHistory = {}
self._memoryHistory = {}
self._customMetrics = {}
self._markers = {}
self._currentFrameStart = nil
self._lastGcCount = collectgarbage("count")
return self
end
---@return nil
function PerformanceProfiler:beginFrame()
self._currentFrameStart = love.timer.getTime()
self._frameCount = self._frameCount + 1
end
---@return nil
function PerformanceProfiler:endFrame()
if not self._currentFrameStart then
return
end
local now = love.timer.getTime()
local frameTime = (now - self._currentFrameStart) * 1000
table.insert(self._frameTimes, frameTime)
if #self._frameTimes > self._maxHistorySize then
table.remove(self._frameTimes, 1)
end
local fps = 1000 / frameTime
table.insert(self._fpsHistory, fps)
if #self._fpsHistory > self._maxHistorySize then
table.remove(self._fpsHistory, 1)
end
local memKb = collectgarbage("count")
table.insert(self._memoryHistory, memKb / 1024)
if #self._memoryHistory > self._maxHistorySize then
table.remove(self._memoryHistory, 1)
end
self._lastGcCount = memKb
self._currentFrameStart = nil
end
---@param name string
---@return nil
function PerformanceProfiler:markBegin(name)
if not self._markers[name] then
self._markers[name] = {
times = {},
totalTime = 0,
count = 0,
minTime = math.huge,
maxTime = 0,
}
end
self._markers[name].startTime = love.timer.getTime()
end
---@param name string
---@return number?
function PerformanceProfiler:markEnd(name)
local marker = self._markers[name]
if not marker or not marker.startTime then
return nil
end
local elapsed = (love.timer.getTime() - marker.startTime) * 1000
marker.startTime = nil
table.insert(marker.times, elapsed)
if #marker.times > self._maxHistorySize then
table.remove(marker.times, 1)
end
marker.totalTime = marker.totalTime + elapsed
marker.count = marker.count + 1
marker.minTime = math.min(marker.minTime, elapsed)
marker.maxTime = math.max(marker.maxTime, elapsed)
return elapsed
end
---@param name string
---@param value number
---@return nil
function PerformanceProfiler:recordMetric(name, value)
if not self._customMetrics[name] then
self._customMetrics[name] = {
values = {},
total = 0,
count = 0,
min = math.huge,
max = -math.huge,
}
end
local metric = self._customMetrics[name]
table.insert(metric.values, value)
if #metric.values > self._maxHistorySize then
table.remove(metric.values, 1)
end
metric.total = metric.total + value
metric.count = metric.count + 1
metric.min = math.min(metric.min, value)
metric.max = math.max(metric.max, value)
end
---@param values table
---@return number
local function calculateMean(values)
if #values == 0 then return 0 end
local sum = 0
for _, v in ipairs(values) do
sum = sum + v
end
return sum / #values
end
---@param values table
---@return number
local function calculateMedian(values)
if #values == 0 then return 0 end
local sorted = {}
for _, v in ipairs(values) do
table.insert(sorted, v)
end
table.sort(sorted)
local mid = math.floor(#sorted / 2) + 1
if #sorted % 2 == 0 then
return (sorted[mid - 1] + sorted[mid]) / 2
else
return sorted[mid]
end
end
---@param values table
---@param percentile number
---@return number
local function calculatePercentile(values, percentile)
if #values == 0 then return 0 end
local sorted = {}
for _, v in ipairs(values) do
table.insert(sorted, v)
end
table.sort(sorted)
local index = math.ceil(#sorted * percentile / 100)
index = math.max(1, math.min(index, #sorted))
return sorted[index]
end
---@return table
function PerformanceProfiler:getReport()
local now = love.timer.getTime()
local totalTime = now - self._startTime
local report = {
totalTime = totalTime,
frameCount = self._frameCount,
averageFps = self._frameCount / totalTime,
frameTime = {
current = self._frameTimes[#self._frameTimes] or 0,
average = calculateMean(self._frameTimes),
median = calculateMedian(self._frameTimes),
min = math.huge,
max = 0,
p95 = calculatePercentile(self._frameTimes, 95),
p99 = calculatePercentile(self._frameTimes, 99),
},
fps = {
current = self._fpsHistory[#self._fpsHistory] or 0,
average = calculateMean(self._fpsHistory),
median = calculateMedian(self._fpsHistory),
min = math.huge,
max = 0,
},
memory = {
current = self._memoryHistory[#self._memoryHistory] or 0,
average = calculateMean(self._memoryHistory),
peak = -math.huge,
min = math.huge,
},
markers = {},
customMetrics = {},
}
-- Calculate frame time min/max
for _, ft in ipairs(self._frameTimes) do
report.frameTime.min = math.min(report.frameTime.min, ft)
report.frameTime.max = math.max(report.frameTime.max, ft)
end
-- Calculate FPS min/max
for _, fps in ipairs(self._fpsHistory) do
report.fps.min = math.min(report.fps.min, fps)
report.fps.max = math.max(report.fps.max, fps)
end
-- Calculate memory min/max/peak
for _, mem in ipairs(self._memoryHistory) do
report.memory.min = math.min(report.memory.min, mem)
report.memory.peak = math.max(report.memory.peak, mem)
end
-- Add marker statistics
for name, marker in pairs(self._markers) do
report.markers[name] = {
average = marker.count > 0 and (marker.totalTime / marker.count) or 0,
median = calculateMedian(marker.times),
min = marker.minTime,
max = marker.maxTime,
count = marker.count,
p95 = calculatePercentile(marker.times, 95),
p99 = calculatePercentile(marker.times, 99),
}
end
-- Add custom metrics
for name, metric in pairs(self._customMetrics) do
report.customMetrics[name] = {
average = metric.count > 0 and (metric.total / metric.count) or 0,
median = calculateMedian(metric.values),
min = metric.min,
max = metric.max,
count = metric.count,
}
end
return report
end
---@param x number?
---@param y number?
---@param width number?
---@param height number?
---@return nil
function PerformanceProfiler:draw(x, y, width, height)
x = x or 10
y = y or 10
width = width or 320
height = height or 280
local report = self:getReport()
love.graphics.setColor(0, 0, 0, 0.85)
love.graphics.rectangle("fill", x, y, width, height)
love.graphics.setColor(1, 1, 1, 1)
local lineHeight = 18
local currentY = y + 10
local padding = 10
-- Title
love.graphics.setColor(0.3, 0.8, 1, 1)
love.graphics.print("Performance Profiler", x + padding, currentY)
currentY = currentY + lineHeight + 5
-- FPS
local fpsColor = {1, 1, 1}
if report.frameTime.current > 16.67 then
fpsColor = {1, 0, 0}
elseif report.frameTime.current > 13.0 then
fpsColor = {1, 1, 0}
else
fpsColor = {0, 1, 0}
end
love.graphics.setColor(fpsColor)
love.graphics.print(string.format("FPS: %.0f (%.2fms)", report.fps.current, report.frameTime.current), x + padding, currentY)
currentY = currentY + lineHeight
-- Average FPS
love.graphics.setColor(0.8, 0.8, 0.8, 1)
love.graphics.print(string.format("Avg: %.0f fps (%.2fms)", report.fps.average, report.frameTime.average), x + padding, currentY)
currentY = currentY + lineHeight
-- Frame time stats
love.graphics.setColor(1, 1, 1, 1)
love.graphics.print(string.format("Min/Max: %.2f/%.2fms", report.frameTime.min, report.frameTime.max), x + padding, currentY)
currentY = currentY + lineHeight
love.graphics.print(string.format("P95/P99: %.2f/%.2fms", report.frameTime.p95, report.frameTime.p99), x + padding, currentY)
currentY = currentY + lineHeight + 3
-- Memory
love.graphics.setColor(0.5, 1, 0.5, 1)
love.graphics.print(string.format("Memory: %.2f MB", report.memory.current), x + padding, currentY)
currentY = currentY + lineHeight
love.graphics.setColor(0.8, 0.8, 0.8, 1)
love.graphics.print(string.format("Peak: %.2f MB | Avg: %.2f MB", report.memory.peak, report.memory.average), x + padding, currentY)
currentY = currentY + lineHeight + 3
-- Total time and frames
love.graphics.setColor(0.7, 0.7, 1, 1)
love.graphics.print(string.format("Frames: %d | Time: %.1fs", report.frameCount, report.totalTime), x + padding, currentY)
currentY = currentY + lineHeight + 5
-- Markers (top 5 by average time)
if next(report.markers) then
love.graphics.setColor(1, 0.8, 0.4, 1)
love.graphics.print("Top Markers:", x + padding, currentY)
currentY = currentY + lineHeight
local sortedMarkers = {}
for name, data in pairs(report.markers) do
table.insert(sortedMarkers, {name = name, average = data.average})
end
table.sort(sortedMarkers, function(a, b) return a.average > b.average end)
love.graphics.setColor(0.9, 0.9, 0.9, 1)
for i = 1, math.min(3, #sortedMarkers) do
local m = sortedMarkers[i]
love.graphics.print(string.format(" %s: %.3fms", m.name, m.average), x + padding, currentY)
currentY = currentY + lineHeight
end
end
end
---@return nil
function PerformanceProfiler:reset()
self._frameCount = 0
self._startTime = love.timer.getTime()
self._frameTimes = {}
self._fpsHistory = {}
self._memoryHistory = {}
self._customMetrics = {}
self._markers = {}
self._currentFrameStart = nil
self._lastGcCount = collectgarbage("count")
end
---@return string
function PerformanceProfiler:exportJSON()
local report = self:getReport()
local function serializeValue(val, indent)
indent = indent or ""
local t = type(val)
if t == "table" then
local items = {}
local isArray = true
local count = 0
for k, _ in pairs(val) do
count = count + 1
if type(k) ~= "number" or k ~= count then
isArray = false
break
end
end
if isArray then
for _, v in ipairs(val) do
table.insert(items, serializeValue(v, indent .. " "))
end
return "[\n" .. indent .. " " .. table.concat(items, ",\n" .. indent .. " ") .. "\n" .. indent .. "]"
else
for k, v in pairs(val) do
table.insert(items, string.format('%s"%s": %s', indent .. " ", k, serializeValue(v, indent .. " ")))
end
return "{\n" .. table.concat(items, ",\n") .. "\n" .. indent .. "}"
end
elseif t == "string" then
return string.format('"%s"', val)
elseif t == "number" then
if val == math.huge then
return "null"
elseif val == -math.huge then
return "null"
elseif val ~= val then -- NaN
return "null"
else
return tostring(val)
end
elseif t == "boolean" then
return tostring(val)
else
return "null"
end
end
return serializeValue(report, "")
end
return PerformanceProfiler