hajimehoshi / ebiten

Ebitengine - A dead simple 2D game engine for Go
https://ebitengine.org
Apache License 2.0
11.13k stars 663 forks source link

internal/graphicscommand: byte pool leaks allocations on `WritePixels` with unmanaged images #3036

Closed galsjel closed 4 months ago

galsjel commented 4 months ago

Ebitengine Version

v2.7.0-alpha.2.0.20231008182829-7e17b25c5666

Operating System

Go Version (go version)

go version go1.22.4 windows/amd64

What steps will reproduce the problem?

go get -u github.com/hajimehoshi/ebiten/v2@7e17b25c56660785a06e263bdfe82fea69004216

or

go get -u github.com/hajimehoshi/ebiten/v2@latest
package main

import (
    "fmt"
    "image"
    "log"
    "runtime"
    "time"

    "github.com/hajimehoshi/ebiten/v2"
    "github.com/hajimehoshi/ebiten/v2/ebitenutil"
)

const game_size = 128

func main() {
    ebiten.SetWindowSize(game_size, game_size)
    ebiten.SetTPS(60)
    ebiten.SetVsyncEnabled(true)
    ebiten.SetScreenClearedEveryFrame(false)
    ebiten.SetWindowResizingMode(ebiten.WindowResizingModeDisabled)
    if err := ebiten.RunGame(&game{}); err != nil {
        log.Fatal(err)
    }
}

// the larger the image, the more memory leaks
const img_size = 128

var empty_img = make([]byte, img_size*img_size*4)

type game struct {
    img        *ebiten.Image
    start_time time.Time
}

func (g *game) Update() error {
    if g.start_time.IsZero() {
        g.start_time = time.Now()
    }
    return nil
}

func (g *game) Draw(screen *ebiten.Image) {
    const unmanaged = false

    if g.img == nil {
        g.img = ebiten.NewImageWithOptions(image.Rect(0, 0, img_size, img_size), &ebiten.NewImageOptions{
            Unmanaged: unmanaged, // XXX: Changing to false stops the leak
        })
    }

    g.img.WritePixels(empty_img)

    screen.Clear()

    var mem runtime.MemStats
    runtime.ReadMemStats(&mem)
    ebitenutil.DebugPrintAt(screen, fmt.Sprintf("%d kB", mem.HeapAlloc/1024), 4, 4)

    elapsed := time.Now().Sub(g.start_time)
    ebitenutil.DebugPrintAt(screen, fmt.Sprint(elapsed), 4, 4+16)

    ebitenutil.DebugPrintAt(screen, fmt.Sprintf("Unmanaged: %v", unmanaged), 4, 4+32)

    // try to stabilize HeapAlloc for a better idea on how much memory is being retained
    runtime.GC()
}

func (g *game) Layout(int, int) (int, int) { return game_size, game_size }

What is the expected result?

Heap allocation to remain relatively stable whether an image is managed or not.

What happens instead?

Heap allocation increases with each WritePixels call on an unmanaged image.

image image

Anything else you feel useful to add?

Setting Unmanaged: false does not leak. This problem appears to have existed since byte pooling was introduced to internal/atlas. This can be observed by comparing results against the commit prior to the one above.

No leak: 2405b7e825c63e02694c516c975d305b92a990b1 Leaking: 7e17b25c56660785a06e263bdfe82fea69004216

hajimehoshi commented 4 months ago

Thanks! More minimized case is:

package main

import (
    "image"
    "runtime"

    "github.com/hajimehoshi/ebiten/v2"
)

type Game struct {
    img   *ebiten.Image
    bytes []byte
}

func (g *Game) Update() error {
    if g.img == nil {
        g.img = ebiten.NewImageWithOptions(image.Rect(0, 0, 128, 128), &ebiten.NewImageOptions{
            Unmanaged: true,
        })
    }
    if g.bytes == nil {
        g.bytes = make([]byte, 128*128*4)
    }
    g.img.WritePixels(g.bytes)

    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    println(m.HeapAlloc)

    return nil
}

func (g *Game) Draw(screen *ebiten.Image) {
}

func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) {
    return 320, 240
}

func main() {
    if err := ebiten.RunGame(&Game{}); err != nil {
        panic(err)
    }
}
hajimehoshi commented 4 months ago

(*graphicscommand.Image).flushBufferedWritePixels is called for managed images, but not for unmanaged images. This is mysterious.

EDIT: OK, IIUC, a managed image shares the same atlas with an internal image that is cleared every frame.

hajimehoshi commented 4 months ago

Please try 71b7cedc5b9907f4b0e8838e71688c9895d0997c (2.7) or 9bc5ed38479b9193961a339ff4b3a59cf243b16c (main), thanks

hajimehoshi commented 4 months ago

This happens only when 1. WritePixels is called on an unmanaged image and 2. the image is never used as a source (or a destination). So this should be rare in the real world situation. For 1., it can happen even with a managed image when the image is in an isolated atlas, but this is very very unlikely.