Files

584 lines
16 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package profiler
import (
"runtime/metrics"
"strconv"
"strings"
"time"
"aego/core/mathx"
"aego/core/stats"
"aego/core/trace"
"aego/editor/core/context"
"aego/editor/core/play"
"aego/editor/core/services"
"aego/editor/core/workspace"
"aego/engine"
"aego/render"
"aego/ui/core"
"aego/ui/widgets"
)
const history = 120
const (
mFrame = iota
mUpdate
mRender
mDraws
mSprites
mGarbage
mGameUpdate
mGameRender
mGameSprites
mGameNodes
mScriptInstr
mScriptHost
mScriptHeap
mCoroutines
mEmits
metricCount
)
type bucket struct {
avg [metricCount]float32
peak [metricCount]float32
frames int
}
type mark struct {
title string
y float32
}
type Panel struct {
ring [history]bucket
head int
n int
valid [metricCount]int
acc bucket
start float64
interval int
paused bool
playing bool
scroll float32
content float32
top float32
scripts context.ScriptStats
hasScripts bool
game play.Stats
mem []metrics.Sample
probe bool
allocs []uint64
slow int
alertAt float64
titles []string
marks []mark
tl Timeline
deps Deps
}
type Deps struct {
Tracer *trace.Tracer
Root string
Notices services.Notices
Sections func() []context.ProfilerSection
Nav services.Navigation
PhaseAllocs func([]uint64) []uint64
SetProbe func(bool)
Memory func() MemStats
Throttled func() bool
}
func (p *Panel) Sections() []string {
return p.titles
}
func (p *Panel) section(ui *core.Context, title string) {
p.titles = append(p.titles, title)
p.marks = append(p.marks, mark{title: title, y: ui.Cursor().Y - p.top})
widgets.Label(ui, title)
}
func (p *Panel) forget(from int) {
for i := from; i < metricCount; i++ {
p.valid[i] = 0
p.acc.avg[i], p.acc.peak[i] = 0, 0
}
}
func (p *Panel) count(m int) int {
return min(p.n, p.valid[m])
}
func intervals() [4]float64 {
return [4]float64{0.1, 0.25, 0.5, 1}
}
var memNames = [...]string{
"/memory/classes/heap/objects:bytes",
"/gc/heap/goal:bytes",
"/memory/classes/total:bytes",
"/sched/goroutines:goroutines",
"/gc/cycles/total:gc-cycles",
}
func New(d Deps) *Panel {
p := &Panel{interval: 1, deps: d}
p.mem = make([]metrics.Sample, len(memNames))
for i, n := range memNames {
p.mem[i].Name = n
}
return p
}
func mib(b uint64) string {
return strconv.FormatFloat(float64(b)/(1<<20), 'f', 1, 64) + " MiB"
}
type MemStats struct {
Heap uint64
Goal uint64
Process uint64
Goroutines uint64
GC uint64
}
func (p *Panel) readMemory() MemStats {
if p.deps.Memory != nil {
return p.deps.Memory()
}
metrics.Read(p.mem)
v := func(i int) uint64 {
if p.mem[i].Value.Kind() == metrics.KindUint64 {
return p.mem[i].Value.Uint64()
}
return 0
}
return MemStats{Heap: v(0), Goal: v(1), Process: v(2), Goroutines: v(3), GC: v(4)}
}
func (p *Panel) memory(ui *core.Context) {
m := p.readMemory()
widgets.LabelDim(ui, "Go heap "+mib(m.Heap)+" goal "+mib(m.Goal)+" process "+mib(m.Process)+
" goroutines "+strconv.FormatUint(m.Goroutines, 10)+" GC "+strconv.FormatUint(m.GC, 10))
if p.hasScripts {
widgets.LabelDim(ui, "script heap "+strconv.Itoa(p.scripts.HeapObjects)+" objects, "+mib(uint64(p.scripts.HeapBytes)))
}
label := "Allocations by phase: off"
if p.probe {
label = "Allocations by phase: on"
}
if widgets.Button(ui, label) && p.deps.SetProbe != nil {
p.probe = !p.probe
p.deps.SetProbe(p.probe)
}
if !p.probe || p.deps.PhaseAllocs == nil {
return
}
p.allocs = p.deps.PhaseAllocs(p.allocs[:0])
for i, n := range p.allocs {
if n > 0 {
widgets.LabelDim(ui, engine.Phase(i).String()+" "+strconv.FormatUint(n, 10)+" objects")
}
}
widgets.LabelDim(ui, "Approximate: counted process-wide from phase start to end; a play world ticked inside a phase counts there too.")
}
func (p *Panel) slowest() string {
f := p.deps.Tracer.Frame(0)
if f == nil {
return ""
}
best, name := uint32(0), ""
for _, e := range f.Events {
if e.Depth == 1 && e.Dur > best {
best, name = e.Dur, p.deps.Tracer.Name(e.Zone)
}
}
return name
}
func (p *Panel) budget(now float64, f *stats.Frame) {
if f == nil {
return
}
if p.deps.Throttled != nil && p.deps.Throttled() {
p.slow = 0
return
}
if f.FrameTime*1000 > 2*1000.0/60 {
p.slow++
} else {
p.slow = 0
}
if p.slow != 3 || now-p.alertAt < 5 || p.deps.Notices == nil {
return
}
p.alertAt = now
msg := "Frame took " + strconv.FormatFloat(f.FrameTime*1000, 'f', 1, 64) + " ms for 3 frames in a row"
if z := p.slowest(); z != "" {
msg += ", slowest zone " + z
}
p.deps.Notices.Notify(context.LevelWarn, msg)
}
func (p *Panel) ID() string {
return workspace.Profiler
}
func (p *Panel) Title() string {
return "Profiler"
}
func (p *Panel) seconds() float64 {
return intervals()[p.interval]
}
func gameLine(g *play.Stats) string {
return "frame " + strconv.FormatUint(g.Frame, 10) +
" tick " + strconv.FormatUint(g.Tick, 10) +
" alpha " + strconv.FormatFloat(g.Alpha, 'f', 3, 64) +
" scale ×" + strconv.FormatFloat(g.Scale, 'g', 3, 64) +
" dropped ticks " + strconv.FormatUint(g.DroppedFixed, 10)
}
func (p *Panel) Record(now float64, f *stats.Frame, r render.Stats, g play.Stats, sc context.ScriptStats, hasSc, playing bool) {
p.game = g
p.budget(now, f)
if playing && !p.playing {
p.forget(mGameUpdate)
}
p.playing = playing
if !playing {
p.hasScripts = false
}
if p.paused || f == nil {
return
}
if hasSc && playing && !p.hasScripts {
p.forget(mScriptInstr)
}
p.scripts, p.hasScripts = sc, hasSc && playing
var s [metricCount]float32
s[mFrame] = float32(f.FrameTime * 1000)
s[mUpdate] = float32(f.UpdateTime * 1000)
s[mRender] = float32(f.RenderTime * 1000)
s[mDraws] = float32(r.DrawCalls)
s[mSprites] = float32(r.Visible)
s[mGarbage] = float32(f.AllocBytes) / 1024
if playing {
s[mGameUpdate] = float32(g.Update * 1000)
s[mGameRender] = float32(g.Render * 1000)
s[mGameSprites] = float32(g.Sprites)
s[mGameNodes] = float32(g.Nodes)
if hasSc {
s[mScriptInstr] = float32(sc.Instructions)
s[mScriptHost] = float32(sc.HostCalls)
s[mScriptHeap] = float32(sc.HeapBytes) / 1024
s[mCoroutines] = float32(sc.Coroutines.Active)
s[mEmits] = float32(sc.Signals.EmitsPerFrame)
}
}
if p.acc.frames == 0 {
p.start = now
}
for i := range s {
p.acc.avg[i] += s[i]
p.acc.peak[i] = max(p.acc.peak[i], s[i])
}
p.acc.frames++
if now-p.start < p.seconds() {
return
}
for i := range p.acc.avg {
p.acc.avg[i] /= float32(p.acc.frames)
}
p.ring[p.head] = p.acc
p.head = (p.head + 1) % history
if p.n < history {
p.n++
}
for i := range p.valid {
p.valid[i] = min(p.valid[i]+1, history)
}
p.acc = bucket{}
}
func ms(d time.Duration) string {
return strconv.FormatFloat(d.Seconds()*1000, 'f', 2, 64)
}
func (p *Panel) scriptTable(ui *core.Context) {
th := &ui.Theme
s := &p.scripts
widgets.LabelDim(ui, "instances "+strconv.Itoa(s.Instances)+
" update "+strconv.Itoa(s.Update)+
" fixed "+strconv.Itoa(s.Fixed)+
" faults "+strconv.Itoa(s.Faults)+
" budget aborts "+strconv.FormatUint(s.BudgetAborts, 10)+
" heap objects "+strconv.Itoa(s.HeapObjects)+
" GC "+strconv.FormatUint(s.GCRuns, 10)+" / "+ms(s.GCTime)+" ms")
co := &s.Coroutines
widgets.LabelDim(ui, "coroutines active "+strconv.Itoa(co.Active)+
" sleeping "+strconv.Itoa(co.Sleeping)+
" resumes/frame "+strconv.FormatUint(co.ResumesPerFrame, 10)+
" canceled "+strconv.FormatUint(co.Canceled, 10))
sg := &s.Signals
widgets.LabelDim(ui, "signal connections "+strconv.Itoa(sg.Connections)+
" emits/frame "+strconv.FormatUint(sg.EmitsPerFrame, 10)+
" handlers/frame "+strconv.FormatUint(sg.HandlersPerFrame, 10))
cols := [5]float32{0, 0.5, 0.64, 0.78, 0.9}
names := [5]string{"script", "instances", "calls", "ms", "faults"}
row := func(vals [5]string, col mathx.Color) {
r := ui.Cursor().Next(0)
ui.PushClip(r)
for i, v := range vals {
ui.Text(v, mathx.Vec2{X: r.X + r.W*cols[i], Y: ui.TextY(r)}, col)
}
ui.PopClip()
}
row(names, th.TextDim)
for _, t := range s.Types {
row([5]string{t.Name, strconv.Itoa(t.Instances), strconv.FormatUint(t.Calls, 10), ms(t.Time), strconv.Itoa(t.Faults)}, th.Text)
}
}
func (p *Panel) at(i int) *bucket {
return &p.ring[(p.head-p.n+i+history)%history]
}
func (p *Panel) series(m int) (last, avg, peak float32) {
n := p.count(m)
if n == 0 {
return 0, 0, 0
}
var sum float32
for i := p.n - n; i < p.n; i++ {
b := p.at(i)
sum += b.avg[m]
peak = max(peak, b.peak[m])
}
return p.at(p.n - 1).avg[m], sum / float32(n), peak
}
func fmtf(v float32, prec int) string {
return strconv.FormatFloat(float64(v), 'f', prec, 32)
}
func (p *Panel) graph(ui *core.Context, title, unit string, prec int, budget float32, col mathx.Color, m int) {
th := &ui.Theme
last, avg, peak := p.series(m)
n := p.count(m)
first := p.n - n
hr := ui.Cursor().Next(th.RowHeight)
nameW, valW := hr.W*0.42, hr.W*0.2
cell := func(c mathx.Rect, s string, tc mathx.Color, align core.Align) {
ui.PushClip(c)
ui.TextIn(c, s, tc, align)
ui.PopClip()
}
ui.Rect(mathx.Rect{X: hr.X, Y: hr.Y + (hr.H-9)/2, W: 3, H: 9}, col)
valCol := th.Text
if budget > 0 && last > budget {
valCol = th.Warn
}
cell(mathx.Rect{X: hr.X + 4, Y: hr.Y, W: nameW - 4, H: hr.H}, title, th.Text, core.AlignLeft)
cell(mathx.Rect{X: hr.X + nameW, Y: hr.Y, W: valW, H: hr.H}, fmtf(last, prec)+unit, valCol, core.AlignLeft)
cell(mathx.Rect{X: hr.X + nameW + valW, Y: hr.Y, W: hr.W - nameW - valW, H: hr.H}, "avg "+fmtf(avg, prec)+" max "+fmtf(peak, prec), th.TextDim, core.AlignRight)
r := ui.Cursor().Next(th.RowHeight * 2.6)
ui.PushClip(r)
defer ui.PopClip()
ui.Rect(r, th.PanelAlt)
top := max(peak, budget, 1e-6) * 1.15
bw := r.W / history
if n > 0 {
floor := r.Y + r.H
off := float32(history-n) * bw
fill, env, line := col, col, col
fill.A, env.A = 0.22, 0.09
yOf := func(v float32) float32 { return r.H * v / top }
sample := func(u float32) (float32, float32) {
k := int(u)
if k >= n-1 {
b := p.at(first + n - 1)
return b.avg[m], b.peak[m]
}
f := u - float32(k)
a, b := p.at(first+k), p.at(first+k+1)
return a.avg[m] + (b.avg[m]-a.avg[m])*f, a.peak[m] + (b.peak[m]-a.peak[m])*f
}
prev := float32(-1)
for px := int(off); px < int(r.W); px++ {
x := r.X + float32(px)
av, pk := sample((float32(px) - off) / bw)
ah, ph := yOf(av), yOf(pk)
ui.Rect(mathx.Rect{X: x, Y: floor - ph, W: 1, H: ph}, env)
ui.Rect(mathx.Rect{X: x, Y: floor - ah, W: 1, H: ah}, fill)
lo, hi := ah, ah
if prev >= 0 {
lo, hi = min(ah, prev), max(ah, prev)
}
ui.Rect(mathx.Rect{X: x, Y: floor - hi - 1, W: 1, H: hi - lo + 2}, line)
prev = ah
}
}
if budget > 0 {
warn := th.Warn
warn.A = 0.7
ui.Rect(mathx.Rect{X: r.X, Y: r.Y + r.H - r.H*budget/top, W: r.W, H: 1}, warn)
}
ui.TextIn(mathx.Rect{X: r.X, Y: r.Y + 2, W: r.W - 5, H: ui.LineHeight()}, fmtf(top, prec)+unit, th.TextDisabled, core.AlignRight)
if !ui.Hit(r) || n == 0 {
return
}
i := int((ui.Mouse().X-r.X)/bw) - (history - n)
if i < 0 || i >= n {
return
}
b := p.at(first + i)
x := r.X + float32(history-n+i)*bw
ui.Rect(mathx.Rect{X: x, Y: r.Y, W: max(1, bw-1), H: r.H}, mathx.Color{R: 1, G: 1, B: 1, A: 0.08})
ago := float64(n-1-i) * p.seconds()
label := fmtf(b.avg[m], prec) + " / " + fmtf(b.peak[m], prec) + unit + " " + strconv.FormatFloat(ago, 'f', 1, 64) + " s ago"
size := ui.TextSize(label)
box := mathx.Rect{X: r.X + r.W - size.X - 10, Y: r.Y + 2, W: size.X + 8, H: size.Y + 2}
ui.Rect(box, th.Background)
ui.Text(label, mathx.Vec2{X: box.X + 4, Y: box.Y + 1}, th.Text)
}
func (p *Panel) toolbar(ui *core.Context, bar mathx.Rect) {
th := &ui.Theme
label := "Pause"
if p.paused {
label = "Resume"
}
if widgets.ButtonRect(ui, ui.ID("pause"), mathx.Rect{X: bar.X, Y: bar.Y, W: 80, H: bar.H}, label) {
p.paused = !p.paused
}
names := []string{"0.1 s", "0.25 s", "0.5 s", "1 s"}
if widgets.Combo(ui, ui.ID("interval"), mathx.Rect{X: bar.X + 86, Y: bar.Y, W: 80, H: bar.H}, &p.interval, names, false) {
p.n, p.head, p.acc = 0, 0, bucket{}
p.valid = [metricCount]int{}
}
if p.n == 0 {
return
}
fps := float64(p.at(p.n-1).frames) / p.seconds()
span := strconv.FormatFloat(p.seconds()*history, 'f', 0, 64)
info := mathx.Rect{X: bar.X + 176, Y: bar.Y, W: max(0, bar.W-176), H: bar.H}
ui.PushClip(info)
ui.Text("fps "+strconv.Itoa(int(fps+0.5))+" window "+span+" s", mathx.Vec2{X: info.X, Y: ui.TextY(bar)}, th.Text)
ui.PopClip()
}
func (p *Panel) sticky(ui *core.Context, body mathx.Rect) {
th := &ui.Theme
cur := -1
for i, m := range p.marks {
if m.y < p.scroll {
cur = i
}
}
if cur < 0 {
return
}
y := body.Y
if next := cur + 1; next < len(p.marks) {
if gap := p.marks[next].y - p.scroll; gap < th.RowHeight {
y -= th.RowHeight - gap
}
}
h := mathx.Rect{X: body.X, Y: y, W: body.W, H: th.RowHeight}
ui.Rect(h, th.Panel)
ui.Rect(mathx.Rect{X: h.X, Y: h.Y + h.H - 1, W: h.W, H: 1}, th.Border)
ui.TextIn(h, p.marks[cur].title, th.Text, core.AlignLeft)
}
func (p *Panel) Draw(f *services.Frame, r mathx.Rect) {
ui := f.UI
th := &ui.Theme
body := r
if ui.MouseOver(ui.ID("scroll"), body) {
if w := ui.ScrollY(th.RowHeight * 3); w != 0 {
p.scroll -= w
}
}
p.scroll = mathx.Clamp(p.scroll, 0, max(0, p.content-body.H+th.Padding))
p.top = body.Y - p.scroll
ui.PushClip(body)
y := p.top
if p.deps.Tracer != nil {
p.tl.Draw(ui, p.deps, mathx.Rect{X: r.X + th.Padding, Y: y + th.Padding, W: r.W - 2*th.Padding, H: TimelineHeight - th.Padding})
y += TimelineHeight
}
bar := mathx.Rect{X: r.X + th.Padding, Y: y + th.Padding, W: r.W - 2*th.Padding, H: th.RowHeight}
p.toolbar(ui, bar)
y = bar.Y + bar.H + th.Spacing
ui.PushCursor(core.Cursor{X: body.X + th.Padding, Y: y, W: body.W - 2*th.Padding, RowH: th.RowHeight, Pad: th.Padding, Space: th.Spacing})
p.titles, p.marks = p.titles[:0], p.marks[:0]
p.section(ui, "Frame")
p.graph(ui, "frame", " ms", 2, 1000.0/60, th.Accent, mFrame)
p.graph(ui, "update", " ms", 2, 0, th.AxisY, mUpdate)
p.graph(ui, "render", " ms", 2, 0, th.AxisX, mRender)
p.graph(ui, "draw calls", "", 0, 0, th.Selection, mDraws)
p.graph(ui, "sprites", "", 0, 0, th.Selection, mSprites)
p.graph(ui, "garbage", " KB", 1, 64, th.Warn, mGarbage)
widgets.Separator(ui)
p.section(ui, "Memory")
p.memory(ui)
if p.playing {
widgets.Separator(ui)
p.section(ui, "Game")
p.graph(ui, "game update", " ms", 2, 0, th.AxisY, mGameUpdate)
p.graph(ui, "game render", " ms", 2, 0, th.AxisX, mGameRender)
p.graph(ui, "game sprites", "", 0, 0, th.Selection, mGameSprites)
p.graph(ui, "game nodes", "", 0, 0, th.Selection, mGameNodes)
widgets.LabelDim(ui, gameLine(&p.game))
if p.hasScripts {
widgets.Separator(ui)
p.section(ui, "Scripts")
p.graph(ui, "script instructions", "", 0, 0, th.Accent, mScriptInstr)
p.graph(ui, "script host calls", "", 0, 0, th.AxisY, mScriptHost)
p.graph(ui, "script heap", " KB", 1, 0, th.Warn, mScriptHeap)
p.graph(ui, "coroutines", "", 0, 0, th.AxisZ, mCoroutines)
p.graph(ui, "signal emits", "", 0, 0, th.Selection, mEmits)
p.scriptTable(ui)
}
}
var secs []context.ProfilerSection
if p.deps.Sections != nil {
secs = p.deps.Sections()
}
for _, sec := range secs {
if sec.Play && !p.playing {
continue
}
widgets.Separator(ui)
p.section(ui, sec.Title)
sec.Draw()
}
if !p.playing {
widgets.LabelDim(ui, playHint(secs))
}
p.content = ui.Cursor().Y - p.top
ui.PopCursor()
p.sticky(ui, body)
ui.PopClip()
}
func playHint(list []context.ProfilerSection) string {
var extra []string
for _, sec := range list {
if sec.Play {
extra = append(extra, strings.ToLower(sec.Title))
}
}
if len(extra) == 0 {
return "Press Play to see game and script timings"
}
return "Press Play to see game, script and " + strings.Join(extra, ", ") + " timings"
}