From 6ccb433dd53e05b7f83db09224cbec1350199a82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Thu, 10 Sep 2026 12:03:15 -0300 Subject: [PATCH] perf: optimize and benchmark terminal frame damage detection Extract frame damage detection into a private reusable implementation and add release benchmarks for representative GUI damage patterns at 720p, 1080p, and 4K. Use tile-major traversal for smaller framebuffers and cache-friendly row-major traversal above 16 MiB, with reusable scratch storage and no whole-frame equality precheck. Determine full-frame updates from the completed damage bounds instead of prematurely falling back based on dirty-tile area. Present benchmark results as a table showing each strategy, the selected production path, its gap to the fastest candidate, and resulting transfer size. --- .../window/terminal/kittyframepresenter.hpp | 1 + .../terminal_frame_damage_benchmark.cpp | 292 ++++++++++++++++++ src/eepp/graphics/textureatlasloader.cpp | 7 +- src/eepp/window/kittyframepresenter.cpp | 59 +--- src/eepp/window/terminal/framedamage.hpp | 121 ++++++++ 5 files changed, 425 insertions(+), 55 deletions(-) create mode 100644 src/benchmarks/terminal_frame_damage_benchmark.cpp create mode 100644 src/eepp/window/terminal/framedamage.hpp diff --git a/include/eepp/window/terminal/kittyframepresenter.hpp b/include/eepp/window/terminal/kittyframepresenter.hpp index e652c128e..1cd4f3551 100644 --- a/include/eepp/window/terminal/kittyframepresenter.hpp +++ b/include/eepp/window/terminal/kittyframepresenter.hpp @@ -50,6 +50,7 @@ class KittyFramePresenter final : public FramePresenter { Frame mRecycle; Frame mPresented; std::vector mDamageRectangles; + std::vector mDamageTiles; std::vector mTransferPixels; std::vector mCompressedPixels; int mZlibCompressionLevel{ 1 }; diff --git a/src/benchmarks/terminal_frame_damage_benchmark.cpp b/src/benchmarks/terminal_frame_damage_benchmark.cpp new file mode 100644 index 000000000..f58654667 --- /dev/null +++ b/src/benchmarks/terminal_frame_damage_benchmark.cpp @@ -0,0 +1,292 @@ +#include "../tests/unit_tests/utest.hpp" + +#include "../eepp/window/terminal/framedamage.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include + +using namespace EE; +using namespace EE::System; +using namespace EE::Window::Private; + +namespace { + +struct DamageRectangle { + Int32 x; + Int32 y; + Int32 width; + Int32 height; +}; + +struct BenchmarkResult { + double microsecondsPerFrame; + size_t transferPixels; + Uint64 checksum; +}; + +using DamageFunction = + std::function&, const std::vector&, + const Math::Sizei&, std::vector& )>; + +FrameDamageResult findFrameDamageWithEqualityCheck( const std::vector& current, + const std::vector& previous, + const Math::Sizei& size, + std::vector& rectangles ) { + if ( 0 == std::memcmp( current.data(), previous.data(), current.size() ) ) { + rectangles.clear(); + return FrameDamageResult::None; + } + return findFrameDamage( current, previous, size, rectangles ); +} + +FrameDamageResult findFrameDamageRowMajor( const std::vector& current, + const std::vector& previous, + const Math::Sizei& size, + std::vector& rectangles, + std::vector& changedTiles ) { + rectangles.clear(); + const Int32 tilesX = ( size.x + FrameDamageTileSize - 1 ) / FrameDamageTileSize; + const Int32 tilesY = ( size.y + FrameDamageTileSize - 1 ) / FrameDamageTileSize; + const size_t rowBytes = static_cast( size.x ) * 3; + Int32 left = size.x; + Int32 top = size.y; + Int32 right = 0; + Int32 bottom = 0; + changedTiles.resize( static_cast( tilesX ) ); + + for ( Int32 tileY = 0; tileY < tilesY; ++tileY ) { + const Int32 y = tileY * FrameDamageTileSize; + const Int32 height = eemin( FrameDamageTileSize, size.y - y ); + std::fill( changedTiles.begin(), changedTiles.end(), 0 ); + Int32 remainingTiles = tilesX; + for ( Int32 row = 0; row < height && remainingTiles; ++row ) { + const Int32 sourceRow = size.y - 1 - ( y + row ); + const Uint8* currentRow = current.data() + static_cast( sourceRow ) * rowBytes; + const Uint8* previousRow = + previous.data() + static_cast( sourceRow ) * rowBytes; + for ( Int32 tileX = 0; tileX < tilesX; ++tileX ) { + if ( changedTiles[static_cast( tileX )] ) + continue; + const Int32 x = tileX * FrameDamageTileSize; + const Int32 width = eemin( FrameDamageTileSize, size.x - x ); + const size_t offset = static_cast( x ) * 3; + if ( 0 != std::memcmp( currentRow + offset, previousRow + offset, + static_cast( width ) * 3 ) ) { + changedTiles[static_cast( tileX )] = 1; + --remainingTiles; + left = eemin( left, x ); + top = eemin( top, y ); + right = eemax( right, x + width ); + bottom = eemax( bottom, y + height ); + } + } + } + } + + if ( left < right && top < bottom ) { + rectangles.push_back( { left, top, right - left, bottom - top } ); + return left == 0 && top == 0 && right == size.x && bottom == size.y + ? FrameDamageResult::Full + : FrameDamageResult::Rectangle; + } + return FrameDamageResult::None; +} + +void changeRectangle( std::vector& pixels, const Math::Sizei& size, Int32 x, Int32 y, + Int32 width, Int32 height ) { + const size_t rowBytes = static_cast( size.x ) * 3; + for ( Int32 row = y; row < y + height; ++row ) { + Uint8* pixel = + pixels.data() + static_cast( row ) * rowBytes + static_cast( x ) * 3; + for ( Int32 column = 0; column < width * 3; ++column ) + pixel[column] ^= static_cast( 0x5A + ( column & 7 ) ); + } +} + +void changeRandomTiles( std::vector& pixels, const Math::Sizei& size, Int32 percentage ) { + Uint32 state = 0x12345678; + const Int32 tilesX = ( size.x + FrameDamageTileSize - 1 ) / FrameDamageTileSize; + const Int32 tilesY = ( size.y + FrameDamageTileSize - 1 ) / FrameDamageTileSize; + for ( Int32 tileY = 0; tileY < tilesY; ++tileY ) { + for ( Int32 tileX = 0; tileX < tilesX; ++tileX ) { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + if ( static_cast( state % 100 ) >= percentage ) + continue; + const Int32 x = tileX * FrameDamageTileSize; + const Int32 y = tileY * FrameDamageTileSize; + changeRectangle( pixels, size, x, y, eemin( FrameDamageTileSize, size.x - x ), + eemin( FrameDamageTileSize, size.y - y ) ); + } + } +} + +BenchmarkResult runBenchmark( const DamageFunction& function, const std::vector& current, + const std::vector& previous, const Math::Sizei& size, + Int32 iterations ) { + static constexpr size_t SampleCount = 5; + std::vector rectangles; + FrameDamageResult result = FrameDamageResult::None; + Uint64 checksum = 0; + function( current, previous, size, rectangles ); + std::array samples; + for ( double& sample : samples ) { + Clock clock; + for ( Int32 iteration = 0; iteration < iterations; ++iteration ) { + result = function( current, previous, size, rectangles ); + checksum += 1 + static_cast( result ) + rectangles.size(); + } + sample = static_cast( clock.getElapsedTime().asMicroseconds() ) / iterations; + } + std::sort( samples.begin(), samples.end() ); + size_t transferPixels = 0; + if ( result == FrameDamageResult::Full ) { + transferPixels = static_cast( size.x ) * size.y; + } else { + for ( const DamageRectangle& rectangle : rectangles ) + transferPixels += static_cast( rectangle.width ) * rectangle.height; + } + return { samples[SampleCount / 2], transferPixels, checksum }; +} + +} // namespace + +UTEST( Benchmark, TerminalFrameDamage ) { + struct Resolution { + Math::Sizei size; + Int32 iterations; + }; + struct Scenario { + const char* name; + std::function&, const Math::Sizei& )> change; + }; + + const std::vector resolutions = { + { { 1280, 720 }, 150 }, { { 1920, 1080 }, 80 }, { { 3840, 2160 }, 20 } }; + const std::vector scenarios = { + { "identical", []( auto&, const auto& ) {} }, + { "caret", + []( auto& pixels, const auto& size ) { + changeRectangle( pixels, size, size.x / 2, size.y / 2, 3, 24 ); + } }, + { "full-width-row", + []( auto& pixels, const auto& size ) { + changeRectangle( pixels, size, 0, size.y / 2, size.x, 32 ); + } }, + { "distant-corners", + []( auto& pixels, const auto& size ) { + changeRectangle( pixels, size, 0, 0, 32, 32 ); + changeRectangle( pixels, size, size.x - 32, size.y - 32, 32, 32 ); + } }, + { "scroll-region", + []( auto& pixels, const auto& size ) { + changeRectangle( pixels, size, size.x / 5, size.y / 5, size.x * 3 / 5, + size.y * 3 / 5 ); + } }, + { "random-10pct", + []( auto& pixels, const auto& size ) { changeRandomTiles( pixels, size, 10 ); } }, + { "random-50pct", + []( auto& pixels, const auto& size ) { changeRandomTiles( pixels, size, 50 ); } }, + { "random-100pct", + []( auto& pixels, const auto& size ) { changeRandomTiles( pixels, size, 100 ); } }, + }; + + std::vector changedTiles; + const DamageFunction currentAlgorithm = []( const auto& current, const auto& previous, + const auto& size, auto& rectangles ) { + return findFrameDamage( current, previous, size, rectangles ); + }; + const DamageFunction equalityFastPath = findFrameDamageWithEqualityCheck; + const DamageFunction rowMajor = [&changedTiles]( const auto& current, const auto& previous, + const auto& size, auto& rectangles ) { + return findFrameDamageRowMajor( current, previous, size, rectangles, changedTiles ); + }; + const DamageFunction equalityRowMajor = [&changedTiles]( const auto& current, + const auto& previous, const auto& size, + auto& rectangles ) { + if ( 0 == std::memcmp( current.data(), previous.data(), current.size() ) ) { + rectangles.clear(); + return FrameDamageResult::None; + } + return findFrameDamageRowMajor( current, previous, size, rectangles, changedTiles ); + }; + const DamageFunction adaptive = [&changedTiles]( const auto& current, const auto& previous, + const auto& size, auto& rectangles ) { + return findFrameDamageAdaptive( current, previous, size, rectangles, changedTiles ); + }; + + tabulate::Table table; + table.add_row( { "Resolution", "Scenario", "Tile-major us", "memcmp + tile us", "Row-major us", + "memcmp + rows us", "Production", "Production us", "Gap to fastest", + "Transfer MiB" } ); + for ( size_t column = 0; column < table[0].size(); ++column ) { + table[0][column] + .format() + .font_align( tabulate::FontAlign::center ) + .font_style( { tabulate::FontStyle::bold } ); + } + for ( const Resolution& resolution : resolutions ) { + const size_t frameBytes = static_cast( resolution.size.x ) * resolution.size.y * 3; + std::vector previous( frameBytes, 0x35 ); + for ( const Scenario& scenario : scenarios ) { + std::vector current = previous; + scenario.change( current, resolution.size ); + std::vector expectedRectangles; + std::vector candidateRectangles; + const FrameDamageResult expected = + currentAlgorithm( current, previous, resolution.size, expectedRectangles ); + for ( const DamageFunction* candidate : + { &equalityFastPath, &rowMajor, &equalityRowMajor, &adaptive } ) { + const FrameDamageResult actual = + ( *candidate )( current, previous, resolution.size, candidateRectangles ); + EXPECT_EQ( static_cast( expected ), static_cast( actual ) ); + EXPECT_EQ( expectedRectangles.size(), candidateRectangles.size() ); + if ( !expectedRectangles.empty() ) { + EXPECT_EQ( expectedRectangles[0].x, candidateRectangles[0].x ); + EXPECT_EQ( expectedRectangles[0].y, candidateRectangles[0].y ); + EXPECT_EQ( expectedRectangles[0].width, candidateRectangles[0].width ); + EXPECT_EQ( expectedRectangles[0].height, candidateRectangles[0].height ); + } + } + const BenchmarkResult baseline = runBenchmark( currentAlgorithm, current, previous, + resolution.size, resolution.iterations ); + const BenchmarkResult equality = runBenchmark( equalityFastPath, current, previous, + resolution.size, resolution.iterations ); + const BenchmarkResult rows = + runBenchmark( rowMajor, current, previous, resolution.size, resolution.iterations ); + const BenchmarkResult equalityRows = runBenchmark( + equalityRowMajor, current, previous, resolution.size, resolution.iterations ); + const bool productionUsesRows = frameBytes >= FrameDamageRowMajorThreshold; + const double productionTime = + productionUsesRows ? rows.microsecondsPerFrame : baseline.microsecondsPerFrame; + const double fastestTime = + eemin( eemin( baseline.microsecondsPerFrame, equality.microsecondsPerFrame ), + eemin( rows.microsecondsPerFrame, equalityRows.microsecondsPerFrame ) ); + const double gapToFastest = ( productionTime / fastestTime - 1.0 ) * 100.0; + const double transferMiB = + static_cast( baseline.transferPixels * 3 ) / ( 1024.0 * 1024.0 ); + table.add_row( { String::format( "%dx%d", resolution.size.x, resolution.size.y ), + scenario.name, String::format( "%.2f", baseline.microsecondsPerFrame ), + String::format( "%.2f", equality.microsecondsPerFrame ), + String::format( "%.2f", rows.microsecondsPerFrame ), + String::format( "%.2f", equalityRows.microsecondsPerFrame ), + productionUsesRows ? "row-major" : "tile-major", + String::format( "%.2f", productionTime ), + String::format( "%.1f%%", gapToFastest ), + String::format( "%.2f", transferMiB ) } ); + EXPECT_GT( + baseline.checksum + equality.checksum + rows.checksum + equalityRows.checksum, 0u ); + } + } + for ( size_t column = 2; column < table[0].size(); ++column ) + table.column( column ).format().font_align( tabulate::FontAlign::right ); + UTEST_PRINT_INFO( ( "\n" + table.str() ).c_str() ); +} diff --git a/src/eepp/graphics/textureatlasloader.cpp b/src/eepp/graphics/textureatlasloader.cpp index 5624c3fc1..bd10f7611 100644 --- a/src/eepp/graphics/textureatlasloader.cpp +++ b/src/eepp/graphics/textureatlasloader.cpp @@ -156,6 +156,7 @@ void TextureAtlasLoader::loadFromStream( IOStream& IOS ) { std::string name( &tTextureHdr.Name[0] ); std::string path( FileSystem::fileRemoveFileName( mTextureAtlasPath ) + name ); + std::string completePath( path ); FileSystem::filePathRemoveProcessPath( path ); //! Checks if the texture is already loaded @@ -173,8 +174,10 @@ void TextureAtlasLoader::loadFromStream( IOStream& IOS ) { mTempAtlass[textureIndex].LoadedTexture = std::move( texture ); } ); } else { - mRL.add( [this, textureIndex, path = std::move( path )] { - TexturePtr texture = TextureFactory::instance()->loadFromFile( path ); + mRL.add( [this, textureIndex, path = std::move( path ), + completePath = std::move( completePath )] { + TexturePtr texture = + TextureFactory::instance()->loadFromFile( completePath ); if ( texture ) mResourceScope->publishLocal( path, texture ); mTempAtlass[textureIndex].LoadedTexture = std::move( texture ); diff --git a/src/eepp/window/kittyframepresenter.cpp b/src/eepp/window/kittyframepresenter.cpp index 263250e84..856e2b84f 100644 --- a/src/eepp/window/kittyframepresenter.cpp +++ b/src/eepp/window/kittyframepresenter.cpp @@ -6,6 +6,8 @@ #include #include +#include "terminal/framedamage.hpp" + #include #include #include @@ -17,11 +19,9 @@ namespace EE { namespace Window { namespace { -constexpr Int32 TileSize = 32; constexpr size_t CompressionThreshold = 4096; constexpr Uint32 StreamImageId = 0x45455050; constexpr Uint32 StreamPlacementId = 1; -enum class DamageResult : Uint8 { None, Rectangle, Full }; bool environmentFlag( const char* name, bool defaultValue ) { const char* value = std::getenv( name ); @@ -53,53 +53,6 @@ int environmentCompressionLevel() { return end != value && *end == '\0' && level >= 0 && level <= 9 ? static_cast( level ) : 1; } -template -DamageResult findDamage( const std::vector& current, const std::vector& previous, - const Math::Sizei& size, std::vector& rectangles ) { - rectangles.clear(); - const Int32 tilesX = ( size.x + TileSize - 1 ) / TileSize; - const Int32 tilesY = ( size.y + TileSize - 1 ) / TileSize; - const size_t rowBytes = static_cast( size.x ) * 3; - const size_t totalPixels = static_cast( size.x ) * size.y; - size_t changedPixels = 0; - Int32 left = size.x; - Int32 top = size.y; - Int32 right = 0; - Int32 bottom = 0; - - for ( Int32 tileY = 0; tileY < tilesY; ++tileY ) { - const Int32 y = tileY * TileSize; - const Int32 height = eemin( TileSize, size.y - y ); - for ( Int32 tileX = 0; tileX < tilesX; ++tileX ) { - bool changed = false; - const Int32 x = tileX * TileSize; - const Int32 width = eemin( TileSize, size.x - x ); - for ( Int32 row = 0; row < height && !changed; ++row ) { - const Int32 sourceRow = size.y - 1 - ( y + row ); - const size_t offset = - static_cast( sourceRow ) * rowBytes + static_cast( x ) * 3; - changed = 0 != std::memcmp( current.data() + offset, previous.data() + offset, - static_cast( width ) * 3 ); - } - if ( changed ) { - changedPixels += static_cast( width ) * height; - left = eemin( left, x ); - top = eemin( top, y ); - right = eemax( right, x + width ); - bottom = eemax( bottom, y + height ); - if ( changedPixels * 5 >= totalPixels * 3 ) - return DamageResult::Full; - } - } - } - - if ( left < right && top < bottom ) { - rectangles.push_back( { left, top, right - left, bottom - top } ); - return DamageResult::Rectangle; - } - return DamageResult::None; -} - bool compressPixels( const std::vector& input, std::vector& output, int level ) { if ( level == 0 || input.size() < CompressionThreshold ) return false; @@ -221,13 +174,13 @@ bool KittyFramePresenter::sendFrame( const Frame& frame ) { return sendTransfer( mTransferPixels, full, true ); } - const DamageResult damage = - findDamage( frame.pixels, mPresented.pixels, frame.size, mDamageRectangles ); - if ( damage == DamageResult::Full ) { + const Private::FrameDamageResult damage = Private::findFrameDamageAdaptive( + frame.pixels, mPresented.pixels, frame.size, mDamageRectangles, mDamageTiles ); + if ( damage == Private::FrameDamageResult::Full ) { extractRectangle( frame, full ); return sendTransfer( mTransferPixels, full, false ); } - if ( damage == DamageResult::None ) + if ( damage == Private::FrameDamageResult::None ) return true; for ( const DamageRectangle& rectangle : mDamageRectangles ) { extractRectangle( frame, rectangle ); diff --git a/src/eepp/window/terminal/framedamage.hpp b/src/eepp/window/terminal/framedamage.hpp new file mode 100644 index 000000000..f4848d657 --- /dev/null +++ b/src/eepp/window/terminal/framedamage.hpp @@ -0,0 +1,121 @@ +#ifndef EE_WINDOW_TERMINAL_FRAMEDAMAGE_HPP +#define EE_WINDOW_TERMINAL_FRAMEDAMAGE_HPP + +#include + +#include +#include + +namespace EE { namespace Window { namespace Private { + +constexpr Int32 FrameDamageTileSize = 32; +constexpr size_t FrameDamageRowMajorThreshold = 16 * 1024 * 1024; + +enum class FrameDamageResult : Uint8 { None, Rectangle, Full }; + +/** Finds the bounding rectangle of changed RGB24 tiles in bottom-up frame buffers. */ +template +FrameDamageResult findFrameDamage( const std::vector& current, + const std::vector& previous, const Math::Sizei& size, + std::vector& rectangles ) { + rectangles.clear(); + const Int32 tilesX = ( size.x + FrameDamageTileSize - 1 ) / FrameDamageTileSize; + const Int32 tilesY = ( size.y + FrameDamageTileSize - 1 ) / FrameDamageTileSize; + const size_t rowBytes = static_cast( size.x ) * 3; + Int32 left = size.x; + Int32 top = size.y; + Int32 right = 0; + Int32 bottom = 0; + + for ( Int32 tileY = 0; tileY < tilesY; ++tileY ) { + const Int32 y = tileY * FrameDamageTileSize; + const Int32 height = eemin( FrameDamageTileSize, size.y - y ); + for ( Int32 tileX = 0; tileX < tilesX; ++tileX ) { + bool changed = false; + const Int32 x = tileX * FrameDamageTileSize; + const Int32 width = eemin( FrameDamageTileSize, size.x - x ); + for ( Int32 row = 0; row < height && !changed; ++row ) { + const Int32 sourceRow = size.y - 1 - ( y + row ); + const size_t offset = + static_cast( sourceRow ) * rowBytes + static_cast( x ) * 3; + changed = 0 != std::memcmp( current.data() + offset, previous.data() + offset, + static_cast( width ) * 3 ); + } + if ( changed ) { + left = eemin( left, x ); + top = eemin( top, y ); + right = eemax( right, x + width ); + bottom = eemax( bottom, y + height ); + } + } + } + + if ( left < right && top < bottom ) { + rectangles.push_back( { left, top, right - left, bottom - top } ); + return left == 0 && top == 0 && right == size.x && bottom == size.y + ? FrameDamageResult::Full + : FrameDamageResult::Rectangle; + } + return FrameDamageResult::None; +} + +/** Uses sequential row traversal for frame buffers too large for cache-friendly tile traversal. */ +template +FrameDamageResult +findFrameDamageAdaptive( const std::vector& current, const std::vector& previous, + const Math::Sizei& size, std::vector& rectangles, + std::vector& changedTiles ) { + if ( current.size() < FrameDamageRowMajorThreshold ) + return findFrameDamage( current, previous, size, rectangles ); + + rectangles.clear(); + const Int32 tilesX = ( size.x + FrameDamageTileSize - 1 ) / FrameDamageTileSize; + const Int32 tilesY = ( size.y + FrameDamageTileSize - 1 ) / FrameDamageTileSize; + const size_t rowBytes = static_cast( size.x ) * 3; + Int32 left = size.x; + Int32 top = size.y; + Int32 right = 0; + Int32 bottom = 0; + changedTiles.resize( static_cast( tilesX ) ); + + for ( Int32 tileY = 0; tileY < tilesY; ++tileY ) { + const Int32 y = tileY * FrameDamageTileSize; + const Int32 height = eemin( FrameDamageTileSize, size.y - y ); + std::memset( changedTiles.data(), 0, changedTiles.size() ); + Int32 remainingTiles = tilesX; + for ( Int32 row = 0; row < height && remainingTiles; ++row ) { + const Int32 sourceRow = size.y - 1 - ( y + row ); + const Uint8* currentRow = current.data() + static_cast( sourceRow ) * rowBytes; + const Uint8* previousRow = + previous.data() + static_cast( sourceRow ) * rowBytes; + for ( Int32 tileX = 0; tileX < tilesX; ++tileX ) { + if ( changedTiles[static_cast( tileX )] ) + continue; + const Int32 x = tileX * FrameDamageTileSize; + const Int32 width = eemin( FrameDamageTileSize, size.x - x ); + const size_t offset = static_cast( x ) * 3; + if ( 0 != std::memcmp( currentRow + offset, previousRow + offset, + static_cast( width ) * 3 ) ) { + changedTiles[static_cast( tileX )] = 1; + --remainingTiles; + left = eemin( left, x ); + top = eemin( top, y ); + right = eemax( right, x + width ); + bottom = eemax( bottom, y + height ); + } + } + } + } + + if ( left < right && top < bottom ) { + rectangles.push_back( { left, top, right - left, bottom - top } ); + return left == 0 && top == 0 && right == size.x && bottom == size.y + ? FrameDamageResult::Full + : FrameDamageResult::Rectangle; + } + return FrameDamageResult::None; +} + +}}} // namespace EE::Window::Private + +#endif