blob: e0b533ca82836799ebf165cda4a028123dbaa3ed [file] [log] [blame]
// Copyright 2020 The Chromium OS Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Package graphics contains graphics-related utility functions for local tests.
package graphics
import (
"context"
"fmt"
"io/ioutil"
"math"
"os"
"strings"
"time"
"chromiumos/tast/errors"
"chromiumos/tast/testing"
)
const (
// The debugfs file with the information on allocated framebuffers for Intel i915 GPUs.
i915FramebufferFile = "/sys/kernel/debug/dri/0/i915_gem_framebuffer"
// The debugfs file with the information on allocated framebuffers for generic
// implementations, e.g. AMD and modern Intel GPUs.
genericFramebufferFile = "/sys/kernel/debug/dri/0/framebuffer"
// Immediately after login there's a lot of graphics activity; wait for a
// minute until it subsides. TODO(crbug.com/1047840): Remove when not needed.
coolDownTimeAfterLogin = 30 * time.Second
// Amount of graphics objects for a given resolution considered bad, regardless of codec.
maxGraphicsObjects = 25
)
// Size represents a Width x Height pair, for example for a video resolution.
type Size struct {
Width int
Height int
}
// Backend contains the necessary methods to interact with the platform debug
// interface and getting readings.
type Backend interface {
// Round implements the platform-specific graphic- or codec- rounding.
Round(value int) int
// ReadFramebufferCount tries to retrieve the number of framebuffers of width
// and height dimensions allocated by the Backend.
ReadFramebufferCount(ctx context.Context, width, height int) (framebuffers int, err error)
}
// I915Backend implements Backend for the Intel i915 case.
type I915Backend struct{}
func i915Backend() *I915Backend {
if _, err := os.Stat(i915FramebufferFile); err != nil {
return nil
}
return &I915Backend{}
}
// Round rounds up value for the Intel platforms and all codecs.
func (g I915Backend) Round(value int) int {
const i915Alignment = 16
// Inspired by Chromium's base/bits.h:Align() function.
return (value + i915Alignment - 1) & ^(i915Alignment - 1)
}
// ReadFramebufferCount tries to open the i915FramebufferFile and count the
// amount of lines of dimensions width x height, which corresponds to the amount
// of framebuffers allocated in the system.
// See https://dri.freedesktop.org/docs/drm/gpu/i915.html
func (g I915Backend) ReadFramebufferCount(ctx context.Context, width, height int) (framebuffers int, e error) {
f, err := os.Open(i915FramebufferFile)
if err != nil {
return framebuffers, errors.Wrap(err, "failed to open dri file")
}
text, err := ioutil.ReadAll(f)
if err != nil {
return framebuffers, errors.Wrap(err, "failed to read dri file")
}
lines := strings.Split(string(text), "\n")
for _, line := range lines {
// The line we're looking for looks like "user size: 1920 x 1080,..."
var fbWidth, fbHeight int
if _, err := fmt.Sscanf(line, "user size: %d x %d", &fbWidth, &fbHeight); err != nil {
continue
}
if fbWidth == width && fbHeight == height {
framebuffers++
}
}
return
}
// GenericBackend implements Backend for the Generic case (Intel and AMD).
type GenericBackend struct{}
func genericBackend() *GenericBackend {
if _, err := os.Stat(genericFramebufferFile); err != nil {
return nil
}
return &GenericBackend{}
}
// Round rounds up value for the Generic Debugfs platforms and all codecs.
func (g GenericBackend) Round(value int) int {
const genericAlignment = 16
// Inspired by Chromium's base/bits.h:Align() function.
return (value + genericAlignment - 1) & ^(genericAlignment - 1)
}
// ReadFramebufferCount tries to open the genericFramebufferFile and count the
// amount of lines of dimensions width x height, which corresponds to the amount
// of framebuffers allocated in the system.
// See https://dri.freedesktop.org/docs/drm/gpu/amdgpu.html
func (g GenericBackend) ReadFramebufferCount(ctx context.Context, width, height int) (framebuffers int, e error) {
f, err := os.Open(genericFramebufferFile)
if err != nil {
return framebuffers, errors.Wrap(err, "failed to open dri file")
}
text, err := ioutil.ReadAll(f)
if err != nil {
return framebuffers, errors.Wrap(err, "failed to read dri file")
}
lines := strings.Split(string(text), "\n")
for _, line := range lines {
// The line we're looking for looks like "...size=1920x1080"
var fbWidth, fbHeight int
if _, err := fmt.Sscanf(line, " size=%dx%d", &fbWidth, &fbHeight); err != nil {
continue
}
if fbWidth == width && fbHeight == height {
framebuffers++
}
}
return
}
// GetBackend tries to get the appropriate platform graphics debug backend and
// returns it, or returns an error.
func GetBackend() (Backend, error) {
// TODO(mcasas): In the future we might want to support systems with several GPUs.
// Prefer the genericBackend.
if be := genericBackend(); be != nil {
return be, nil
}
if be := i915Backend(); be != nil {
return be, nil
}
return nil, errors.New("could not find any Graphics backend")
}
// compareGraphicsMemoryBeforeAfter compares the graphics memory consumption
// before and after running the payload function, using the backend. The amount
// of graphics buffer during payload execution must also be non-zero.
func compareGraphicsMemoryBeforeAfter(ctx context.Context, payload func() error, backend Backend, roundedWidth, roundedHeight int) (err error) {
var before, during, after int
if before, err = readStableObjectCount(ctx, backend, roundedWidth, roundedHeight); err != nil {
return errors.Wrap(err, "failed to get the framebuffer object count")
}
testing.ContextLog(ctx, "Running the payload() and measuring the number of graphics objects during its execution")
c := make(chan error)
go func(c chan error) {
c <- payload()
}(c)
// Note: We don't wait for the ReadFramebufferCount() to finish, just keep
// measuring until we get a non-zero value in during, for further comparison
// below.
go func() {
const pollTimeout = 10 * time.Second
const pollInterval = 100 * time.Millisecond
_ = testing.Poll(ctx, func(ctx context.Context) error {
// TODO(crbug.com/1047514): instead of blindly sampling the amount of
// objects during the test and comparing them further down, verify them
// here directly.
if during, _ = backend.ReadFramebufferCount(ctx, roundedWidth, roundedHeight); during == before {
return errors.New("Still waiting for graphics objects")
}
return nil
}, &testing.PollOptions{Timeout: pollTimeout, Interval: pollInterval})
}()
err = <-c
if err != nil {
return err
}
if after, err = readStableObjectCount(ctx, backend, roundedWidth, roundedHeight); err != nil {
return errors.Wrap(err, "failed to get the framebuffer object count")
}
if before != after {
return errors.Wrapf(err, "graphics objects of size %d x %d do not coincide: before=%d, after=%d", roundedWidth, roundedHeight, before, after)
}
if during == before {
return errors.Wrapf(err, "graphics objects of size %d x %d did not increase during play back: before=%d, during=%d", roundedWidth, roundedHeight, before, during)
}
testing.ContextLogf(ctx, "Graphics objects of size %d x %d before=%d, during=%d, after=%d", roundedWidth, roundedHeight, before, during, after)
return nil
}
// monitorGraphicsMemoryDuring verifies that the graphics memory consumption
// while running the payload function, using the backend, does not spiral out
// of control, by comparing it to the appropriate threshold.
func monitorGraphicsMemoryDuring(ctx context.Context, payload func() error, backend Backend, roundedSizes []Size, threshold int) (err error) {
testing.ContextLog(ctx, "Running the payload() and measuring the number of graphics objects during its execution")
c := make(chan error)
go func(c chan error) {
c <- payload()
}(c)
const pollInterval = 1 * time.Second
ticker := time.NewTicker(pollInterval)
for {
select {
case <-ctx.Done():
ticker.Stop()
return errors.New("test timed out")
case pErr := <-c:
ticker.Stop()
return pErr
case <-ticker.C:
for _, roundedSize := range roundedSizes {
count, _ := backend.ReadFramebufferCount(ctx, roundedSize.Width, roundedSize.Height)
if count > threshold {
// TODO(mcasas): find a way to kill payload() at this point.
ticker.Stop()
err := errors.Errorf("too many objects of size %d x %d, got: %d, threshold: %d", roundedSize.Width, roundedSize.Height, count, threshold)
select {
case <-c:
case <-ctx.Done():
}
return err
}
}
}
}
}
// VerifyGraphicsMemory uses the backend to detect memory leaks during or after
// the execution of payload.
func VerifyGraphicsMemory(ctx context.Context, payload func() error, backend Backend, sizes []Size) (err error) {
testing.ContextLogf(ctx, "Cooling down %v after log in", coolDownTimeAfterLogin)
if err := testing.Sleep(ctx, coolDownTimeAfterLogin); err != nil {
return errors.Wrap(err, "error while cooling down after log in")
}
var roundedSizes []Size
for _, size := range sizes {
roundedSizes = append(roundedSizes, Size{Width: backend.Round(size.Width), Height: backend.Round(size.Height)})
}
if len(sizes) == 1 {
return compareGraphicsMemoryBeforeAfter(ctx, payload, backend, roundedSizes[0].Width, roundedSizes[0].Height)
}
return monitorGraphicsMemoryDuring(ctx, payload, backend, roundedSizes, maxGraphicsObjects)
}
// readStableObjectCount waits until a given graphics object count obtained with
// backend is stable, up to a certain timeout, progressively relaxing a
// similarity threshold criteria.
func readStableObjectCount(ctx context.Context, backend Backend, width, height int) (objectCount int, err error) {
const (
pollingInterval = 1 * time.Second
// Time to wait for the object count to be stable.
waitTimeout = 120 * time.Second
// Threshold (in percentage) below which the object count is considered stable.
objectCountThresholdBase = 0.1
// Maximum threshold (in percentage) for the object count to be considered stable.
objectCountThresholdMax = 2.0
// Maximum steps of relaxing the object count similarity threshold.
relaxingThresholdSteps = 5
)
startTime := time.Now()
delta := (objectCountThresholdMax - objectCountThresholdBase) / (relaxingThresholdSteps - 1)
testing.ContextLogf(ctx, "Waiting at most %v for stable graphics object count, threshold will be gradually relaxed from %.1f%% to %.1f%%",
waitTimeout, objectCountThresholdBase, objectCountThresholdMax)
for i := 0; i < relaxingThresholdSteps; i++ {
idlePercent := objectCountThresholdBase + (delta * float64(i))
timeout := waitTimeout / relaxingThresholdSteps
testing.ContextLogf(ctx, "Waiting up to %v for object count to settle within %.1f%% (%d/%d)",
timeout.Round(time.Second), idlePercent, i+1, relaxingThresholdSteps)
objectCount, err = waitForStableReadings(ctx, backend, width, height, timeout, pollingInterval, idlePercent)
if err == nil {
testing.ContextLogf(ctx, "Waiting for object count stabilisation took %v (value %d, threshold: %.1f%%)",
time.Now().Sub(startTime).Round(time.Second), objectCount, idlePercent)
return objectCount, nil
}
}
return objectCount, err
}
// waitForStableReadings reads values using backend and waits for up to timeout
// for the moving average of the last numReadings to settle within threshold.
func waitForStableReadings(ctx context.Context, backend Backend, width, height int, timeout, interval time.Duration, threshold float64) (reading int, err error) {
// Keep the last numReadings for moving average purposes. Make it half the
// size that the current timeout and interval would allow.
numReadings := int(math.Floor(float64(timeout / (2.0 * interval))))
var currentNumReadings int
var values = make([]int, numReadings)
err = testing.Poll(ctx, func(ctx context.Context) error {
var e error
reading, e = backend.ReadFramebufferCount(ctx, width, height)
if e != nil {
return testing.PollBreak(errors.Wrap(e, "failed measuring"))
}
values[currentNumReadings%numReadings] = reading
currentNumReadings++
if currentNumReadings < numReadings {
return errors.Errorf("need more values (got: %d and want: %d)", currentNumReadings, numReadings)
}
average := mean(values)
if math.Abs(float64(reading)-average) > threshold {
return errors.Errorf("reading %d is not within %.1f of %.1f", reading, threshold, average)
}
return nil
}, &testing.PollOptions{Timeout: timeout, Interval: interval})
return reading, err
}
// mean returns the average of values.
func mean(values []int) float64 {
var sum float64
for _, v := range values {
sum += float64(v)
}
return sum / float64(len(values))
}