native: frame-rate setting, idle frame rate and battery power telemetry

- MtFrameRate setting (30 / 60 default / display max) as app.Get/SetFrameRateMode,
  with a "画面帧率" row injected into the system option dialog (mt_framerate); saved
  in the SDL pref path.
- FrameRatePolicy in the live loop: 30 fps after 15 s without input (not while auto
  hunting), display switched to 60 Hz unless the highest mode, ADPF
  setPreferPowerEfficiency, vsync pacing when the display already runs at the target.
  --fps-mode / --idle-fps / --idle-after; --fps-cap / --refresh-rate still pin a test run.
- Perf log: battery current/voltage/power, plugged, fps_mode, idle columns;
  perf_summary groups power by mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-09-29 18:14:19 +09:00
co-authored by Claude Opus 5.5
parent c2cc093ed0
commit 82503315c6
9 changed files with 453 additions and 19 deletions
@@ -37,19 +37,20 @@ public class MainActivity extends SDLActivity {
super.onCreate(savedInstanceState);
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE);
applyImmersiveMode();
chosenRefreshRate = applyRefreshRate(requestedRefreshRate(launchArgs()));
// --refresh-rate N pins the display mode for a test run; otherwise native_render switches it with
// the frame-rate setting (setDisplayRefreshRate).
final float pinned = requestedRefreshRate(launchArgs());
if (pinned > 0)
applyRefreshRate(pinned);
}
// The display rate picked in onCreate (0: left to the system); handed to native as --refresh-rate.
private int chosenRefreshRate = 0;
private String launchArgs() {
Intent intent = getIntent();
String args = intent != null ? intent.getStringExtra("args") : null;
return args == null || args.trim().isEmpty() ? DEFAULT_ARGS : args;
}
// --refresh-rate N (0 = system default); absent = the display's highest rate.
// --refresh-rate N (0 = system default); -1 when absent.
private static float requestedRefreshRate(String args) {
final String[] tokens = args.trim().split("\\s+");
for (int i = 0; i + 1 < tokens.length; ++i) {
@@ -141,6 +142,52 @@ public class MainActivity extends SDLActivity {
return tenths / 10f;
}
// Called from native code (android_perf.h): the display mode whose refresh rate is closest to `hz`
// (the highest one when hz < 0), for the frame-rate setting. The panel then need not scan out at 120 Hz
// for a 60 fps game.
public void setDisplayRefreshRate(float hz) {
runOnUiThread(() -> applyRefreshRate(hz));
}
// Called from native code (android_perf.h): the highest refresh rate of the current resolution.
public float maxRefreshRate() {
final Display display = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R ? getDisplay()
: getWindowManager().getDefaultDisplay();
if (display == null)
return -1f;
float best = display.getRefreshRate();
final Display.Mode current = display.getMode();
for (Display.Mode mode : display.getSupportedModes())
if (mode.getPhysicalWidth() == current.getPhysicalWidth() &&
mode.getPhysicalHeight() == current.getPhysicalHeight())
best = Math.max(best, mode.getRefreshRate());
return best;
}
// Called from native code (android_perf.h): BATTERY_PROPERTY_CURRENT_NOW as reported -- uA per the
// docs, mA on some vendors; the sign convention varies too (android_perf::battery_power sorts both
// out). 0 if unknown.
public float batteryCurrentRaw() {
final BatteryManager manager = (BatteryManager) getSystemService(BATTERY_SERVICE);
if (manager == null)
return 0f;
final int raw = manager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW);
return raw == Integer.MIN_VALUE ? 0f : raw;
}
// Called from native code (android_perf.h): battery voltage in mV, -1 if unknown.
public float batteryVoltage() {
final Intent battery = registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED));
return battery != null ? battery.getIntExtra(BatteryManager.EXTRA_VOLTAGE, -1) : -1f;
}
// Called from native code (android_perf.h): 1 while a charger is connected (the current then no
// longer measures the game's draw), 0 otherwise.
public float batteryPlugged() {
final Intent battery = registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED));
return battery != null && battery.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) != 0 ? 1f : 0f;
}
// Called from native code (android_perf.h): the rate the display runs at now.
public float currentRefreshRate() {
final Display display = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R ? getDisplay()
@@ -204,8 +251,6 @@ public class MainActivity extends SDLActivity {
// Test phase: FPS readout in the top-left corner. Drop this line for release builds.
if (!args.contains("--show-fps"))
args = args.trim() + " --show-fps";
if (!args.contains("--refresh-rate") && chosenRefreshRate > 0)
args = args.trim() + " --refresh-rate " + chosenRefreshRate;
return args.trim().split("\\s+");
}
@@ -0,0 +1,31 @@
#pragma once
// PORT: the player's frame-rate setting (system option dialog, app.Get/SetFrameRateMode), shared between
// the script thread and the host's render loop (native_render/main.cpp), which paces frames by it and
// saves it. 40250 has no counterpart: the PC client ran at the monitor's rate.
#include <atomic>
namespace MtFrameRate {
enum EMode {
MODE_SAVER = 0, // 30 fps
MODE_STANDARD = 1, // 60 fps, 30 after a while without input
MODE_HIGH = 2, // the display's highest rate (120 on current phones), 30 after a while without input
MODE_COUNT
};
inline std::atomic<int>& Mode()
{
static std::atomic<int> mode{MODE_STANDARD};
return mode;
}
inline int Get() { return Mode().load(std::memory_order_relaxed); }
inline void Set(int mode)
{
if (mode >= 0 && mode < MODE_COUNT)
Mode().store(mode, std::memory_order_relaxed);
}
} // namespace MtFrameRate
@@ -0,0 +1,75 @@
// The frame-rate row of the system option dialog (PORT; 40250 has no counterpart, the PC client ran at
// the monitor's rate), run by PythonBoot::RunMainScript as module mt_framerate. It wraps
// uiSystemOption.OptionDialog's __Load and fills the row the original layout leaves empty at y 210 (its
// shadow slider is commented out), in the style of the fog buttons above it. The value is
// app.Get/SetFrameRateMode (platform/EterBase/FrameRateMode.h); the host paces frames by it and saves it.
// Strings are UTF-8 here and go through player.AutoHuntLocalize into the locale's code page.
static const char c_szFrameRateOptionScript[] = R"PY(# -*- coding: utf-8 -*-
import app
import player
import ui
import uiSystemOption
L = player.AutoHuntLocalize
SMALL_BUTTON = "d:/ymir work/ui/public/small_button_%02d.sub"
ROW_Y = 210
MODE_TEXTS = ("30帧", "60帧", "最高")
def _Refresh(buttons):
mode = app.GetFrameRateMode()
for i in xrange(len(buttons)):
if i == mode:
buttons[i].Down()
else:
buttons[i].SetUp()
def _Select(buttons, mode):
app.SetFrameRateMode(mode)
_Refresh(buttons)
def _Attach(dialog):
board = dialog.GetChild("board")
label = ui.TextLine()
label.SetParent(board)
label.SetPosition(30, ROW_Y + 2)
label.SetText(L("画面帧率"))
label.Show()
buttons = []
for i in xrange(len(MODE_TEXTS)):
button = ui.RadioButton()
button.SetParent(board)
button.SetUpVisual(SMALL_BUTTON % 1)
button.SetOverVisual(SMALL_BUTTON % 2)
button.SetDownVisual(SMALL_BUTTON % 3)
button.SetPosition(110 + 50 * i, ROW_Y)
button.SetText(L(MODE_TEXTS[i]))
button.SetEvent(lambda mode=i: _Select(buttons, mode))
button.Show()
buttons.append(button)
_Refresh(buttons)
dialog.mtFrameRateLabel = label
dialog.mtFrameRateButtons = buttons
def __Install():
load = uiSystemOption.OptionDialog._OptionDialog__Load
def Load(self):
load(self)
try:
_Attach(self)
except:
import dbg
dbg.TraceError("mt_framerate: cannot add the frame-rate row")
uiSystemOption.OptionDialog._OptionDialog__Load = Load
__Install()
)PY";
@@ -876,6 +876,7 @@ namespace PythonBoot
// have to run once the root scripts are importable; a failure leaves the game without them, not broken.
#include "AutoHuntScript.inc"
#include "RegisterScript.inc"
#include "FrameRateOptionScript.inc"
static void install_script_module(const char* c_szModule, const char* c_szScript)
{
@@ -931,6 +932,7 @@ bool RunMainScript(const char* lpCmdLine, std::string* error)
{
install_script_module("mt_autohunt", c_szAutoHuntScript);
install_script_module("mt_register", c_szRegisterScript);
install_script_module("mt_framerate", c_szFrameRateOptionScript);
}
return true;
}
@@ -3,6 +3,7 @@
#include "resource.h"
#include "PythonApplication.h"
#include "../EterLib/Camera.h"
#include "../../platform/EterBase/FrameRateMode.h" // PORT: frame-rate setting
extern bool PERF_CHECKER_RENDER_GAME;
extern D3DXCOLOR g_fSpecularColor;
@@ -1191,6 +1192,21 @@ PyObject* appLogoClose(PyObject* poSelf, PyObject* poArgs)
return Py_BuildNone();
}
// PORT: the frame-rate setting of the system option dialog (platform/EterBase/FrameRateMode.h).
PyObject* appGetFrameRateMode(PyObject* poSelf, PyObject* poArgs)
{
return Py_BuildValue("i", MtFrameRate::Get());
}
PyObject* appSetFrameRateMode(PyObject* poSelf, PyObject* poArgs)
{
int iMode;
if (!PyTuple_GetInteger(poArgs, 0, &iMode))
return Py_BuildException();
MtFrameRate::Set(iMode);
return Py_BuildNone();
}
void initapp()
{
static PyMethodDef s_methods[] =
@@ -1257,6 +1273,8 @@ void initapp()
{ "GetCursorPosition", appGetCursorPosition, METH_VARARGS },
{ "GetRandom", appGetRandom, METH_VARARGS },
{ "GetFrameRateMode", appGetFrameRateMode, METH_VARARGS }, // PORT
{ "SetFrameRateMode", appSetFrameRateMode, METH_VARARGS }, // PORT
{ "RunPythonFile", appRunPythonFile, METH_VARARGS },
{ "IsWebPageMode", appIsWebPageMode, METH_VARARGS },
{ "ShowWebPage", appShowWebPage, METH_VARARGS },
+66
View File
@@ -10,11 +10,18 @@
// - PerformanceHint: ADPF APerformanceHint (API 33). The loop reports each frame's CPU work against the
// frame budget, so the governor raises the clocks before a frame misses instead of idling the CPU at
// its lowest step between short bursts (the 556-748 MHz seen in perf-20260929-153205.csv).
// prefer_power_efficiency(): APerformanceHint_setPreferPowerEfficiency (API 35), set by the frame-rate
// policy (main.cpp FrameRatePolicy) except in the highest mode.
// - set_display_refresh_rate() / max_refresh_rate(): MainActivity switches the display mode at run time
// for the frame-rate setting (60 Hz unless the setting wants more).
// - battery_power(): BatteryManager current + voltage for the perf log's power columns; the power is only
// meaningful off the charger (USB adb counts as a charger: test over wireless adb).
//
// Every NDK entry point is looked up at run time: minSdk is 24. On other platforms all of this is a no-op.
#include <SDL3/SDL.h>
#include <cmath>
#include <cstdint>
#include <vector>
@@ -50,11 +57,48 @@ inline double call_activity_float(const char* name) {
return hz;
}
inline void call_activity_set_float(const char* name, float value) {
auto* env = static_cast<JNIEnv*>(SDL_GetAndroidJNIEnv());
auto activity = static_cast<jobject>(SDL_GetAndroidActivity());
if (!env || !activity) return;
jclass cls = env->GetObjectClass(activity);
if (jmethodID method = env->GetMethodID(cls, name, "(F)V"))
env->CallVoidMethod(activity, method, jfloat(value));
if (env->ExceptionCheck()) env->ExceptionClear();
env->DeleteLocalRef(cls);
env->DeleteLocalRef(activity);
}
inline double display_refresh_rate() { return call_activity_float("currentRefreshRate"); }
// The highest refresh rate the display offers at its current resolution; -1 if unknown.
inline double max_refresh_rate() { return call_activity_float("maxRefreshRate"); }
// Switches the display mode to the refresh rate closest to `hz` (MainActivity.preferredDisplayModeId).
inline void set_display_refresh_rate(float hz) { call_activity_set_float("setDisplayRefreshRate", hz); }
// The app-vsync rate (Choreographer); -1 until the first measurement.
inline double render_rate() { return call_activity_float("currentRenderRate"); }
inline double battery_celsius() { return call_activity_float("batteryTemperature"); }
struct BatteryPower {
double current_ma = 0; // drawn from the battery, positive while discharging
double voltage_mv = -1;
double power_mw = -1; // current x voltage; -1 while plugged in or unknown
bool plugged = false;
};
// BatteryManager's CURRENT_NOW is uA by the docs but mA on some vendors, and its sign convention varies:
// a game draws well over 20 mA, so a magnitude above 20000 can only be uA; unplugged, the whole current is
// the draw. Plugged in, the charger supplies part of it and the reading says nothing about the game.
inline BatteryPower battery_power() {
BatteryPower out;
const double raw = call_activity_float("batteryCurrentRaw");
out.current_ma = std::fabs(raw) > 20000.0 ? std::fabs(raw) / 1000.0 : std::fabs(raw);
out.voltage_mv = call_activity_float("batteryVoltage");
out.plugged = call_activity_float("batteryPlugged") > 0.5;
if (!out.plugged && out.current_ma > 0 && out.voltage_mv > 0)
out.power_mw = out.current_ma * out.voltage_mv / 1000.0;
return out;
}
// false when the call is unavailable (API < 30) or rejected.
inline bool request_frame_rate(SDL_Window* window, float fps) {
using SetFrameRate = int32_t (*)(ANativeWindow*, float, int8_t);
@@ -79,6 +123,8 @@ public:
update_target_ = reinterpret_cast<UpdateTarget>(dlsym(lib, "APerformanceHint_updateTargetWorkDuration"));
report_ = reinterpret_cast<Report>(dlsym(lib, "APerformanceHint_reportActualWorkDuration"));
close_ = reinterpret_cast<Close>(dlsym(lib, "APerformanceHint_closeSession"));
prefer_efficiency_ = reinterpret_cast<PreferEfficiency>(
dlsym(lib, "APerformanceHint_setPreferPowerEfficiency"));
if (!get_manager_ || !create_ || !report_ || !close_) return false;
void* manager = get_manager_();
if (!manager) return false;
@@ -103,17 +149,29 @@ public:
if (session_ && actual_ns > 0) report_(session_, actual_ns);
}
// API 35: the scheduler may favour efficiency cores and lower clocks for the session's threads.
// false when unavailable.
bool prefer_power_efficiency(bool enabled) {
if (!session_ || !prefer_efficiency_) return false;
if (enabled == prefer_efficiency_on_) return true;
prefer_efficiency_on_ = enabled;
return prefer_efficiency_(session_, enabled) == 0;
}
private:
using GetManager = void* (*)();
using CreateSession = void* (*)(void*, const int32_t*, size_t, int64_t);
using UpdateTarget = int (*)(void*, int64_t);
using Report = int (*)(void*, int64_t);
using Close = void (*)(void*);
using PreferEfficiency = int (*)(void*, bool);
GetManager get_manager_ = nullptr;
CreateSession create_ = nullptr;
UpdateTarget update_target_ = nullptr;
Report report_ = nullptr;
Close close_ = nullptr;
PreferEfficiency prefer_efficiency_ = nullptr;
bool prefer_efficiency_on_ = false;
void* session_ = nullptr;
std::int64_t target_ns_ = 0;
};
@@ -125,7 +183,14 @@ inline double display_refresh_rate() {
return mode ? double(mode->refresh_rate) : -1.0;
}
inline double render_rate() { return display_refresh_rate(); }
inline double max_refresh_rate() { return display_refresh_rate(); }
inline void set_display_refresh_rate(float) {}
inline double battery_celsius() { return -1.0; }
struct BatteryPower {
double current_ma = 0, voltage_mv = -1, power_mw = -1;
bool plugged = false;
};
inline BatteryPower battery_power() { return {}; }
inline bool request_frame_rate(SDL_Window*, float) { return false; }
class PerformanceHint {
public:
@@ -133,6 +198,7 @@ public:
bool active() const { return false; }
void set_target(std::int64_t) {}
void report(std::int64_t) {}
bool prefer_power_efficiency(bool) { return false; }
};
inline int32_t current_thread_id() { return 0; }
#endif
+149 -7
View File
@@ -12,6 +12,7 @@
#include "platform/ScriptLib/PythonBoot.h"
#include "platform/UserInterface/ServerClock.h"
#include "platform/EterBase/TraceErrorObserver.h"
#include "platform/EterBase/FrameRateMode.h"
#include "../tests/port_login_flow_server.h"
#include <thread>
#endif
@@ -1033,6 +1034,13 @@ public:
out.write(reinterpret_cast<const char*>(file.data()), std::streamsize(file.size()));
}
// Seconds since the last touch, mouse or key event; 0 while a finger or mouse button is held (a held
// joystick or attack button sends no events). The frame-rate policy's idle test.
double input_idle_seconds() const {
if (fingers_down_ > 0 || buttons_down_ > 0) return 0.0;
return std::chrono::duration<double>(std::chrono::steady_clock::now() - last_input_).count();
}
void request_screenshot(std::string path, std::uint64_t frame) {
screenshot_path_ = std::move(path);
screenshot_frame_ = frame;
@@ -1277,6 +1285,7 @@ public:
#endif
SDL_Event event;
while (SDL_PollEvent(&event)) {
note_input(event);
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
// Touches arrive as SDL_EVENT_FINGER_*; the touch-synthesized mouse copy
// would register as a second finger (101) and turn every drag into a pinch.
@@ -3570,6 +3579,27 @@ private:
VkExtent2D extent_{};
std::vector<VkImageView> image_views_;
std::vector<VkImage> swapchain_images_;
void note_input(const SDL_Event& event) {
switch (event.type) {
case SDL_EVENT_FINGER_DOWN: ++fingers_down_; break;
case SDL_EVENT_FINGER_UP:
case SDL_EVENT_FINGER_CANCELED: fingers_down_ = std::max(0, fingers_down_ - 1); break;
case SDL_EVENT_MOUSE_BUTTON_DOWN: ++buttons_down_; break;
case SDL_EVENT_MOUSE_BUTTON_UP: buttons_down_ = std::max(0, buttons_down_ - 1); break;
case SDL_EVENT_WINDOW_FOCUS_LOST: case SDL_EVENT_WILL_ENTER_BACKGROUND:
// An up event lost with the focus must not keep the client out of the idle rate for good.
fingers_down_ = buttons_down_ = 0;
break;
case SDL_EVENT_FINGER_MOTION: case SDL_EVENT_MOUSE_MOTION: case SDL_EVENT_MOUSE_WHEEL:
case SDL_EVENT_KEY_DOWN: case SDL_EVENT_KEY_UP: case SDL_EVENT_TEXT_INPUT:
case SDL_EVENT_WINDOW_FOCUS_GAINED: case SDL_EVENT_DID_ENTER_FOREGROUND: break;
default: return;
}
last_input_ = std::chrono::steady_clock::now();
}
std::chrono::steady_clock::time_point last_input_ = std::chrono::steady_clock::now();
int fingers_down_ = 0, buttons_down_ = 0;
// --screenshot-out: read back the swapchain image of one frame into a BMP file.
std::string screenshot_path_;
std::uint64_t screenshot_frame_ = 0;
@@ -3739,6 +3769,74 @@ int g_fps_cap = 0;
// --refresh-rate N: the surface's preferred frame rate (Android ANativeWindow_setFrameRate; MainActivity
// also picks the matching display mode). 0 leaves the system default.
int g_refresh_rate = 0;
// --fps-mode N: the frame-rate setting for this run (a test override: the saved setting stays);
// --idle-fps N: the rate after --idle-after S seconds without input (0 = never lower it).
int g_fps_mode = -1;
int g_idle_fps = 30;
double g_idle_after_s = 15.0;
// The frame-rate setting (system option dialog, platform/EterBase/FrameRateMode.h) turned into a frame
// rate, a display refresh rate and the ADPF budget; --fps-cap / --refresh-rate pin those for a test run.
// Frames the player does not watch closely cost the same power as the ones they do, so after a while
// without input (and no auto hunt running) the rate drops to --idle-fps until the next touch. The display
// is asked for 60 Hz unless the setting wants more: a 120 Hz panel scanning out a 60 fps game costs power
// for nothing.
class FrameRatePolicy {
public:
struct Decision {
int mode = -1;
int fps = 0; // frames per second the loop aims at
int display_hz = 0; // the refresh rate asked of the display
bool idle = false;
bool operator==(const Decision&) const = default;
};
bool pinned() const { return g_fps_cap > 0 || g_refresh_rate > 0; }
void load() {
path_ = settings_path();
int mode = MtFrameRate::MODE_STANDARD;
if (std::ifstream in{path_}; in) in >> mode;
if (g_fps_mode >= 0) mode = g_fps_mode;
MtFrameRate::Set(mode);
saved_mode_ = MtFrameRate::Get();
max_hz_ = int(std::lround(android_perf::max_refresh_rate()));
if (max_hz_ < 30) max_hz_ = 60;
}
Decision decide(double idle_seconds, bool auto_hunt) {
Decision d;
d.mode = MtFrameRate::Get();
if (d.mode != saved_mode_) save(saved_mode_ = d.mode);
const int active = d.mode == MtFrameRate::MODE_SAVER ? 30
: d.mode == MtFrameRate::MODE_HIGH ? max_hz_ : std::min(60, max_hz_);
d.idle = g_idle_fps > 0 && g_idle_fps < active && idle_seconds >= g_idle_after_s && !auto_hunt;
d.fps = d.idle ? g_idle_fps : active;
d.display_hz = d.mode == MtFrameRate::MODE_HIGH && !d.idle ? max_hz_ : std::min(60, max_hz_);
return d;
}
int max_hz() const { return max_hz_; }
private:
static std::string settings_path() {
std::string dir;
if (char* pref = SDL_GetPrefPath("metin2port", "client")) {
dir = pref;
SDL_free(pref);
}
return dir + "frame_rate.cfg";
}
void save(int mode) {
std::ofstream out{path_, std::ios::trunc};
out << mode << '\n';
}
std::string path_;
int saved_mode_ = -1;
int max_hz_ = 60;
};
bool py_exec(const std::string& code) {
std::string err;
@@ -4083,16 +4181,21 @@ int run_live_client(
perf_log.set_refresh_rate_source([] { return android_perf::display_refresh_rate(); });
perf_log.set_render_rate_source([] { return android_perf::render_rate(); });
perf_log.set_battery_source([] { return android_perf::battery_celsius(); });
perf_log.set_power_source([] {
const auto p = android_perf::battery_power();
return native_perf::PowerSample{p.current_ma, p.voltage_mv, p.power_mw, p.plugged};
});
// ADPF: each frame's CPU work (everything but the vsync/fence waits and the cap's sleep) against the
// frame budget. Opened once the script thread exists, i.e. here.
using steady = std::chrono::steady_clock;
// The target is 80% of the frame period: the governor settles the clocks so the reported work just
// meets the target, so a target equal to the period leaves every other frame late (cap 60 on the
// test phone: 54 fps against the period, 60 against 80% of it).
const auto frame_budget = [] {
const int fps = g_fps_cap > 0 ? g_fps_cap : (g_refresh_rate > 0 ? g_refresh_rate : 60);
return std::chrono::nanoseconds(800'000'000LL / fps);
}();
const auto budget_for = [](int fps) { return std::chrono::nanoseconds(800'000'000LL / std::max(1, fps)); };
FrameRatePolicy policy;
policy.load();
const auto frame_budget = budget_for(
g_fps_cap > 0 ? g_fps_cap : (g_refresh_rate > 0 ? g_refresh_rate : 60));
android_perf::PerformanceHint hint;
{
std::vector<int32_t> tids{android_perf::current_thread_id()};
@@ -4106,10 +4209,46 @@ int run_live_client(
int completed = 0;
std::vector<double> frame_ms;
if (frames > 0) frame_ms.reserve(static_cast<std::size_t>(frames));
const auto cap_period = g_fps_cap > 0 ? std::chrono::nanoseconds(1'000'000'000LL / g_fps_cap)
: std::chrono::nanoseconds(0);
auto cap_period = g_fps_cap > 0 ? std::chrono::nanoseconds(1'000'000'000LL / g_fps_cap)
: std::chrono::nanoseconds(0);
auto next_frame_at = steady::now();
FrameRatePolicy::Decision decision;
auto display_checked_at = steady::time_point{};
// The loop sleeps to the policy's rate unless the display already runs at it (vsync then paces the
// loop, and a second clock beating against it would drop frames).
const auto apply_policy = [&] {
if (policy.pinned()) return;
const auto now = steady::now();
const auto next = policy.decide(renderer.input_idle_seconds(), PythonBoot::AutoHuntIsEnabled());
const bool changed = !(next == decision);
if (changed) {
if (next.display_hz != decision.display_hz) {
android_perf::set_display_refresh_rate(float(next.display_hz));
android_perf::request_frame_rate(renderer.sdl_window(), float(next.display_hz));
}
hint.set_target(budget_for(next.fps).count());
// Efficiency cores and lower clocks, except where the setting asks for the highest rate.
hint.prefer_power_efficiency(next.mode != MtFrameRate::MODE_HIGH || next.idle);
perf_log.set_fps_cap(next.fps);
perf_log.set_frame_policy(next.mode, next.idle);
SDL_Log("frame rate: mode %d -> %d fps, display %d Hz%s", next.mode, next.fps, next.display_hz,
next.idle ? " (idle)" : "");
decision = next;
}
if (changed || now - display_checked_at > std::chrono::seconds(1)) {
display_checked_at = now;
const double hz = android_perf::display_refresh_rate();
const bool vsync_paced = hz > 0 && std::fabs(hz - decision.fps) < 2.0;
const auto period = vsync_paced ? std::chrono::nanoseconds(0)
: std::chrono::nanoseconds(1'000'000'000LL / decision.fps);
if (period != cap_period) {
cap_period = period;
next_frame_at = now;
}
}
};
while ((frames == 0 || completed < frames) && renderer.poll(true) && PythonBoot::IsAppLooping()) {
apply_policy();
double pace_ms = 0.0;
if (cap_period.count()) {
// Fixed cadence: a late frame starts the next one at once, a frame more than one period
@@ -4255,6 +4394,9 @@ int main(int argc, char** argv) {
else if (arg == "--perf-log") g_perf_log = true;
else if (arg == "--fps-cap" && i+1 < argc) g_fps_cap = std::max(0, std::stoi(argv[++i]));
else if (arg == "--refresh-rate" && i+1 < argc) g_refresh_rate = std::max(0, std::stoi(argv[++i]));
else if (arg == "--fps-mode" && i+1 < argc) g_fps_mode = std::stoi(argv[++i]);
else if (arg == "--idle-fps" && i+1 < argc) g_idle_fps = std::max(0, std::stoi(argv[++i]));
else if (arg == "--idle-after" && i+1 < argc) g_idle_after_s = std::max(0.0, std::stod(argv[++i]));
#endif
else if (arg == "--fake-idle-ms" && i+1 < argc) setenv("MT_FAKE_IDLE_MS", argv[++i], 1);
else if (arg == "--frames-in-flight" && i+1 < argc) frames_in_flight = std::stoi(argv[++i]);
@@ -4266,7 +4408,7 @@ int main(int argc, char** argv) {
"[--draws N] [--triangles-per-draw N] [--capture FILE] [--animate-first-draw] "
"[--animate-bones] [--no-vsync] [--live-client DIR] [--fake-mobs N] "
"[--gpu-skinning|--no-gpu-skinning] [--no-terrain] [--mobile] [--capture-out FILE] [--screenshot-out FILE.bmp] [--show-fps] [--perf-log] "
"[--fps-cap N] [--refresh-rate HZ] [--frames-in-flight 1|2] [--msaa 1|2|4|8] [--fake-idle-ms MS] [--cpu-shadow]");
"[--fps-cap N] [--refresh-rate HZ] [--fps-mode 0|1|2] [--idle-fps N] [--idle-after S] [--frames-in-flight 1|2] [--msaa 1|2|4|8] [--fake-idle-ms MS] [--cpu-shadow]");
}
#ifdef MT_NATIVE_HAS_LIVE_CLIENT
if (native_perf::PerfLog::requested_by_env()) g_perf_log = true;
+47 -5
View File
@@ -47,6 +47,12 @@ struct RendererTotals {
const std::string* last_texture = nullptr; // name of the texture uploaded last
};
// One battery reading (android_perf::battery_power). power_mw < 0: unknown or on a charger.
struct PowerSample {
double current_ma = 0, voltage_mv = -1, power_mw = -1;
bool plugged = false;
};
struct FrameInput {
double frame_ms = 0; // whole loop iteration
double app_ms = 0; // PythonBoot::UIUpdate: the script thread's Process() plus the baton handoff
@@ -61,6 +67,7 @@ public:
static constexpr double kIntervalS = 2.0;
static constexpr double kHitchMs = 100.0;
static constexpr int kKeepFiles = 10;
static constexpr double kPowerSampleS = 0.25;
static bool requested_by_env() {
const char* env = std::getenv("MT_PERF_LOG");
@@ -75,7 +82,11 @@ public:
void set_render_rate_source(std::function<double()> source) { render_rate_ = std::move(source); }
// Battery temperature in C (Android: the ACTION_BATTERY_CHANGED sticky intent; sysfs is SELinux-denied).
void set_battery_source(std::function<double()> source) { battery_ = std::move(source); }
// Sampled every kPowerSampleS and averaged over the row: one reading swings with each frame's burst.
void set_power_source(std::function<PowerSample()> source) { power_ = std::move(source); }
void set_fps_cap(int cap) { fps_cap_ = cap; }
// The frame-rate setting (FrameRateMode.h) and whether the idle rate is in effect, as of the row's end.
void set_frame_policy(int mode, bool idle) { fps_mode_ = mode; idle_ = idle; }
const std::string& path() const { return path_; }
// `context` goes into the file header (device, present mode, MSAA, size...).
@@ -104,7 +115,8 @@ public:
"render_game_ms,shadow_raster_ms,py_ms,py_calls,py_missing,host_ms,render_ms,pace_ms,sync_ms,fence_ms,"
"prepare_ms,geo_upload_ms,tex_upload_ms,"
"submit_ms,present_ms,gpu_ms,draws,skinned_draws,ui_batches,vertices,geo_uploads,geo_kb,tex_uploads,tex_kb,"
"script_cpu,main_cpu,cpu_mhz,cpu_max_mhz,thermal,batt_c,rss_mb,last_texture\n");
"script_cpu,main_cpu,cpu_mhz,cpu_max_mhz,thermal,batt_c,rss_mb,batt_ma,batt_mv,power_mw,plugged,fps_mode,idle,"
"last_texture\n");
std::fflush(file_);
MtPerf::Enabled() = true;
MtPerf::Take();
@@ -163,9 +175,25 @@ public:
script_cpu_ = game.scriptCpu;
const auto now = std::chrono::steady_clock::now();
if (power_ && std::chrono::duration<double>(now - power_sampled_at_).count() >= kPowerSampleS) {
power_sampled_at_ = now;
const PowerSample sample = power_();
power_sum_.current_ma += sample.current_ma;
power_sum_.voltage_mv += sample.voltage_mv;
if (sample.power_mw >= 0) {
power_sum_.power_mw += sample.power_mw;
++power_valid_;
}
power_sum_.plugged = power_sum_.plugged || sample.plugged;
++power_samples_;
}
const double elapsed = std::chrono::duration<double>(now - window_start_).count();
if (elapsed < kIntervalS) return;
write_row(elapsed, map_name());
power_sum_ = {};
power_sum_.voltage_mv = 0;
power_sum_.power_mw = 0;
power_samples_ = power_valid_ = 0;
window_ = {};
frame_times_.clear();
window_start_ = now;
@@ -354,13 +382,18 @@ private:
const int main_cpu = current_cpu();
const double refresh = refresh_rate_ ? refresh_rate_() : -1.0;
const double render_rate = render_rate_ ? render_rate_() : -1.0;
const double ns = double(power_samples_);
const double batt_ma = power_samples_ ? power_sum_.current_ma / ns : -1.0;
const double batt_mv = power_samples_ ? power_sum_.voltage_mv / ns : -1.0;
// Only when every sample of the row was off the charger.
const double power_mw = power_valid_ && power_valid_ == power_samples_ ? power_sum_.power_mw / ns : -1.0;
std::fprintf(file_,
"%.1f,%s,%d,%llu,%.1f,%.1f,%.1f,%d,%.2f,%.2f,%.2f,%d,%d,"
"%.2f,%.2f,%.2f,%.3f,%.3f,%.3f,%.3f,%.3f,%.3f,"
"%.3f,%.3f,%.3f,%.1f,%.1f,%.3f,%.2f,%.2f,%.2f,%.2f,"
"%.2f,%.3f,%.3f,"
"%.3f,%.3f,%.2f,%llu,%llu,%llu,%llu,%.2f,%.1f,%.2f,%.1f,"
"%d,%d,%s,%s,%d,%.1f,%.0f,%s\n",
"%d,%d,%s,%s,%d,%.1f,%.0f,%.0f,%.0f,%.0f,%d,%d,%d,%s\n",
t, csv_field(map).c_str(), actors_, (unsigned long long)w.frames, fps, refresh, render_rate, fps_cap_, w.frame_ms / n,
p95, sorted.back(), jank33, jank50,
w.app_ms / n, g(MtPerf::SECTION_PROCESS), (w.app_ms - w.game_ms[MtPerf::SECTION_PROCESS]) / n,
@@ -373,17 +406,20 @@ private:
w.submit_ms / n, w.present_ms / n, gpu, (unsigned long long)draws_, (unsigned long long)skinned_draws_,
(unsigned long long)ui_batches_, (unsigned long long)vertices_, double(w.geo_uploads) / n,
double(w.geo_bytes) / 1024.0 / n, double(w.tex_uploads) / n, double(w.tex_bytes) / 1024.0 / n,
script_cpu_, main_cpu, mhz.c_str(), max_mhz.c_str(), thermal, batt, rss, csv_field(last_texture_).c_str());
script_cpu_, main_cpu, mhz.c_str(), max_mhz.c_str(), thermal, batt, rss, batt_ma, batt_mv, power_mw,
int(power_sum_.plugged), fps_mode_, int(idle_), csv_field(last_texture_).c_str());
std::fflush(file_);
if (!w.tex_uploads) last_texture_.clear();
log("perf fps=%.1f @%.0fHz/%.0f cap=%d frame=%.1f/p95 %.1f/max %.1f jank33=%d | app=%.1f (upd=%.1f rnd=%.1f "
"shadow=%.1f py=%.1f) | render=%.1f (sync=%.1f fence=%.1f prep=%.1f up=%.1f/%.1f) pace=%.1f gpu=%.1f | "
"draws=%llu actors=%d map=%s thermal=%d batt=%.1f cpu=%s max=%s tex_uploads=%.2f last_tex=%s",
"draws=%llu actors=%d map=%s thermal=%d batt=%.1f cpu=%s max=%s tex_uploads=%.2f | pwr=%.0fmW %.0fmA%s mode=%d%s "
"last_tex=%s",
fps, refresh, render_rate, fps_cap_, w.frame_ms / n, p95, sorted.back(), jank33, w.app_ms / n,
g(MtPerf::SECTION_UPDATE_GAME), g(MtPerf::SECTION_RENDER_GAME), g(MtPerf::SECTION_SHADOW_RASTER),
g(MtPerf::SECTION_PYTHON), w.render_ms / n, w.sync_ms / n, w.fence_ms / n, w.prepare_ms / n,
w.geo_upload_ms / n, w.tex_upload_ms / n, w.pace_ms / n, gpu, (unsigned long long)draws_, actors_,
map.c_str(), thermal, batt, mhz.c_str(), max_mhz.c_str(), double(w.tex_uploads) / n, last_texture_.c_str());
map.c_str(), thermal, batt, mhz.c_str(), max_mhz.c_str(), double(w.tex_uploads) / n, power_mw, batt_ma,
power_sum_.plugged ? " plugged" : "", fps_mode_, idle_ ? " idle" : "", last_texture_.c_str());
}
void write_hitch(const Window& f, const MtPerf::SCounters& game) {
@@ -413,6 +449,12 @@ private:
std::function<double()> refresh_rate_;
std::function<double()> render_rate_;
std::function<double()> battery_;
std::function<PowerSample()> power_;
std::chrono::steady_clock::time_point power_sampled_at_{};
PowerSample power_sum_{0, 0, 0, false};
int power_samples_ = 0, power_valid_ = 0;
int fps_mode_ = -1;
bool idle_ = false;
std::string last_texture_;
};
+13
View File
@@ -74,6 +74,19 @@ def main():
print("median fps %.1f frame p95 %.1f ms jank>33ms %d frames jank>50ms %d frames" % (
median(rows, "fps"), median(rows, "frame_p95_ms"),
sum(int(num(r, "jank33")) for r in rows), sum(int(num(r, "jank50")) for r in rows)))
if "power_mw" in rows[0]:
# power_mw is -1 in a window with any sample on the charger: the battery current is then the
# charger's surplus, not the app's draw. Split by frame-rate mode and idle.
print("power (battery, unplugged windows only; plugged windows show current only)")
groups = {}
for r in rows:
groups.setdefault((r.get("fps_mode", "?"), r.get("idle", "?")), []).append(r)
for (mode, idle), rs in sorted(groups.items()):
powered = [r for r in rs if num(r, "power_mw") >= 0]
print(" mode %s%s: %d windows fps %.1f %s current %.0f mA" % (
mode, " idle" if idle == "1" else "", len(rs), median(rs, "fps"),
("power %.0f mW (%d windows)" % (median(powered, "power_mw"), len(powered))) if powered
else "power n/a (plugged)", median(rs, "batt_ma")))
print("median actors %.0f draws %.0f skinned %.0f python calls/frame %.0f (missing %.0f)" % (
median(rows, "actors"), median(rows, "draws"), median(rows, "skinned_draws"),
median(rows, "py_calls"), median(rows, "py_missing")))