From 76dbb980b5aa8b8d0f2c04b1bd83d94d03ce5079 Mon Sep 17 00:00:00 2001 From: Ichthyostega Date: Mon, 27 Apr 2026 17:26:34 +0200 Subject: [PATCH] BufferProvider: more testing and refinements of the heuristic Seems to work now roughly as expected, so that the next layers of functionality can be built on top. It seems impossible to determine the viability of this whole approach without taking the full buffer memory management into use with a real-world load scenario. --- src/vault/mem/local-mem-pool.hpp | 95 +++++++++++++------ tests/vault/mem/local-mem-pool-test.cpp | 110 ++++++++++++++++------ wiki/thinkPad.ichthyo.mm | 119 ++++++++++++------------ 3 files changed, 208 insertions(+), 116 deletions(-) diff --git a/src/vault/mem/local-mem-pool.hpp b/src/vault/mem/local-mem-pool.hpp index de4acdf34..d0e23369d 100644 --- a/src/vault/mem/local-mem-pool.hpp +++ b/src/vault/mem/local-mem-pool.hpp @@ -36,8 +36,31 @@ ** can be found locally, new memory must be acquired right away, since ** render jobs are assumed to run through without blocking waits. ** + ** # Allocation selection heuristic + ** ** To avoid the hazard of the pool accumulating an increasing number of surplus - ** allocations over time, less frequently used blocks are culled using a heuristic. + ** allocations over time, less frequently used blocks are culled using a heuristic. + ** - The memory block to use for a given allocation request is selected foremost + ** based on the _match quality_, so that the amount of wasted excess memory + ** is ideally rather small. + ** - Furthermore, more frequently used blocks are preferred + ** - Whenever a block can be used, its score is increased, yet when some + ** block is to small to satisfy a given request, it is penalised + ** - less successful blocks can be culled by heuristic partial clean-up + ** - the hard-coded tuning parameters allow to adjust the weight assigned + ** to the quality of the match and the usage score. + ** + ** In addition, currently unused blocks can be [reserved](\ref reserve), + ** yet only if their size is reasonably close to the requested size. Using + ** this feature at the beginning of a render job allows to probe which buffer + ** allocations could be satisfied from memory already associated to the current + ** worker thread; any further memory can be requested from the central allocator, + ** to be sent asynchronously to the local pool. However, since there is always the + ** possibility that some allocation request can not be satisfied immediately, using + ** only the blocks in the local pool, an additional mechanism should be provided + ** to allocate the missing memory directly, and in a blocking way, since the + ** requests to BufferProvider happen from within the Render Node pull() + ** and are expected to be immediately successful. ** ** @see local-mem-pool-test.cpp ** @see local-buffer-store.hpp @@ -51,17 +74,9 @@ #include "lib/error.hpp" -//#include "vault/mem/buffer-provider-setup.hpp" -//#include "vault/mem/engine-buffer-metadata.hpp" -//#include "vault/mem/buffer-metadata.hpp" -#include "vault/mem/buffhandle.hpp" //////////////TODO -//#include "lib/local-slice.hpp" -//#include "lib/depend.hpp" -#include "lib/iter-explorer.hpp" -#include "lib/format-string.hpp" -#include "lib/util.hpp" +#include "vault/mem/buffhandle.hpp" #include "lib/nocopy.hpp" -#include "lib/meta/trait.hpp"//////////////TODO +#include "lib/util.hpp" #include #include @@ -78,7 +93,7 @@ namespace mem { const size_t INQUEUE_SIZ = 30; ///< initial size of the lock-free provision queue - const uint MATCH_SCORE = 10; ///< score to add when a buffer can be used to satisfy a request + const uint MATCH_SCORE = 10; ///< score to add when a buffer can be used to satisfy a request const uint MISFIT_PENALTY = 2; ///< reduce score whenever a buffer is too small to be useful const double USAGE_WEIGHT = 0.9; ///< degree to which very frequent usage counteracts waste of memory const double CLOSE_MATCH = 0.2; ///< fraction of wasted memory that still counts as /good match/ @@ -95,6 +110,20 @@ namespace mem { /** * Low-level Building block for Render Engine memory management. + * A pool of buffer memory blocks can be maintained, reserved + * and used to satisfy allocation requests relying on a best + * match heuristic. Each usage of an allocation is scored, + * so that frequently used and well matching blocks are + * preferred and less useful blocks can be expunged. + * @note uses a `boost::lockfree::queue` as input channel + * to receive new allocations from a central manager, + * while unused allocations are passed into a consumer + * functor on clean-up. + * @remark the intention is to use a thread-local instance + * of LocalMemPool, connected to the EngineBufferManger + * thorough lock-free queues. This setup allows to + * implement the BufferProvider::BufferStore interface + * for use in a massive multithreaded environment. */ class LocalMemPool { @@ -103,7 +132,6 @@ namespace mem { Buff* mem; size_t siz; }; - static_assert (std::is_trivially_copy_assignable_v); struct Block { @@ -117,7 +145,6 @@ namespace mem { : alloc{move(alloc)} { } }; - static_assert (std::is_trivially_copy_assignable_v); using InQueue = boost::lockfree::queue; using BlockList = std::list; @@ -224,7 +251,7 @@ namespace mem { reAdd (Buff* mem) { ingest(); - Block* block = UN_CONST (anyMatch (mem)); + Block* block = UN_CONST (find (mem)); if (not block) throw err::Logic{"returning unknown allocation to LocalMemPool"}; if (not block->used) @@ -324,6 +351,7 @@ namespace mem { } // recalibrate all score levels maxScore_ -= killLevel; + maxScore_ = util::max (maxScore_, 1); return removed; } @@ -358,7 +386,7 @@ namespace mem { } Block const* - anyMatch (Buff* mem) const + find (Buff* mem) const { return findMatch ([mem](Block const& b){ return mem == b.alloc.mem; }); } @@ -374,10 +402,10 @@ namespace mem { if (sizRequest > block.alloc.siz) block.score -= MISFIT_PENALTY; else - { + { // compute heuristic selector for best match double wasted = block.alloc.siz - sizRequest; double wasteScore = wasted / block.alloc.siz; - double usageFact = block.score / maxScore_; + double usageFact = block.score / maxScore_; double selector = 1.0 - wasteScore * (1.0 - usageFact * USAGE_WEIGHT); if (selector > bestSelector) { @@ -387,29 +415,31 @@ namespace mem { } } if (match) - { + { // increase score of the winning block ENSURE (match->alloc.siz >= sizRequest); match->score += int(MATCH_SCORE * double(sizRequest) / match->alloc.siz); - if (match->score > maxScore_) // reduce score by waste factor as malus + if (match->score > maxScore_) // reduce score by waste factor as penalty maxScore_ = match->score; } return match; } + /** a »backdoor« for unit testing */ friend class PoolDiagnostic; }; + /** wrapper to inspect internals from a unit test */ class PoolDiagnostic : util::MoveOnly { using Block = LocalMemPool::Block; - LocalMemPool& memPool_; + LocalMemPool const& memPool_; public: - PoolDiagnostic (LocalMemPool& lmp) + PoolDiagnostic (LocalMemPool const& lmp) : memPool_{lmp} { } @@ -439,18 +469,27 @@ namespace mem { ,[](Block const& b){ return not b.used; }); } - auto - allScores() + bool + isFree (Buff* mem) { - return lib::explore (memPool_.blocks_) - .transform([](Block const& b){ return std::string{util::_Fmt{"%d:%02d"} % b.alloc.siz % b.score}; }) - .effuse(); + Block const* entry = memPool_.find (mem); + REQUIRE (entry, "Test refers to entry unknown in pool"); + return not entry->used; + } + + int32_t + getScore (Buff* mem) + { + Block const* entry = memPool_.find (mem); + REQUIRE (entry, "Test refers to entry unknown in pool"); + return entry->score; } }; + /** entrance point to inspection for test */ inline PoolDiagnostic - watch (LocalMemPool& lmp) + watch (LocalMemPool const& lmp) { return PoolDiagnostic{lmp}; } diff --git a/tests/vault/mem/local-mem-pool-test.cpp b/tests/vault/mem/local-mem-pool-test.cpp index 822cf72dd..25838ebe7 100644 --- a/tests/vault/mem/local-mem-pool-test.cpp +++ b/tests/vault/mem/local-mem-pool-test.cpp @@ -18,28 +18,17 @@ #include "test/run.hpp" #include "test/test-helper.hpp" -//#include "vault/mem/engine-buffer-metadata.hpp" #include "vault/mem/local-mem-pool.hpp" #include "lib/iter-stack.hpp" -//#include "lib/depend-inject.hpp" -//#include "lib/thread.hpp" -//#include "lib/error.hpp" #include "lib/format-util.hpp" -#include "test/diagnostic-output.hpp" -//using std::this_thread::yield; -//using lib::Thread; using util::join; namespace vault { namespace mem { namespace test { -// using LERR_(LOGIC); -// using LERR_(LIFECYCLE); - - namespace { // Test helper inline constexpr Buff* @@ -72,6 +61,11 @@ namespace test { /**********************************************************************//** * @test verify handling of prospective buffer allocations in a local pool. + * - demonstrate usage of the API + * - show how allocations are selected and discarded + * @remark this covers the mechanics how allocation entries are + * managed in the pool, without any infrastructure and + * notably without the connection to a global manager. */ class LocalMemPool_test : public Test { @@ -79,12 +73,9 @@ namespace test { virtual void run (Arg) { - seedRand(); - simpleUse(); verify_matchAlloc(); verify_selectAlloc(); - verify_pruneAlloc(); } @@ -195,7 +186,17 @@ namespace test { - /** @test the pool picks allocations heuristically + /** @test the pool picks and discards allocations heuristically: + * - allocations are only marked as reserved when they are + * pretty close above the requested amount of memory + * - however, all allocations in the pool are used + * to handle requests, irrespective of reservation status + * - the prime criterion to select an entry is the match quality + * - yet frequently used blocks are preferred for a rather poor match + * - each usage adds to the score, but better matches score higher + * - but when an entry is too small to be used, it is marked with a penalty + * - entries with low score are cleaned and returned first, while + * successful entries with high score are retained */ void verify_selectAlloc() @@ -217,9 +218,9 @@ namespace test { auto [m2,s2] = pool.retrieve (SIZ10); auto [m3,s3] = pool.retrieve (SIZ10); auto [m4,s4] = pool.retrieve (SIZ20+SIZ20); // this one can certainly not be satisfied - CHECK (MEM11 == m1); // the first request gets the good match - CHECK (MEM33 == m2); // the following ones are satisfied with an over-allocation - CHECK (MEM32 == m3); + CHECK (m1 == MEM11); // the first request gets the good match + CHECK (m2 == MEM33); // the following ones are satisfied with an over-allocation + CHECK (m3 == MEM32); CHECK (not m4); CHECK (watch(pool).cntFree() == 1); CHECK (watch(pool).size() == 4); @@ -232,16 +233,69 @@ namespace test { VERIFY_FAIL ("unknown allocation", pool.reAdd (fake_Buffer(12345))) VERIFY_FAIL ("not marked as used", pool.reAdd (MEM31)) CHECK (watch(pool).cntFree() == 4); -SHOW_EXPR(join(watch(pool).allScores())) - } - - - - /** @test - */ - void - verify_pruneAlloc() - { + + // all entries in the pool were scored.... + CHECK (watch(pool).getScore(MEM31) == -2); // never used and -2 penalty for being once to small to be useful (for SIZ20+SIZ20) + CHECK (watch(pool).getScore(MEM32) == 3); // these were used once, but as over-allocation using only 1/3 of their size SIZ30 + CHECK (watch(pool).getScore(MEM33) == 3); + CHECK (watch(pool).getScore(MEM11) == 10); // this one was used as a perfect match and thus got the full score + + // this score determines how they are used further.... + pool.add (MEM12, SIZ10); + auto [m5,s5] = pool.retrieve (SIZ10-5); + auto [m6,s6] = pool.retrieve (SIZ10); + auto [m7,s7] = pool.retrieve (SIZ10); + auto [m8,s8] = pool.retrieve (SIZ20); + auto [m9,s9] = pool.retrieve (SIZ20); + CHECK (m5 == MEM11); // used first because it matches and has the best score + CHECK (m6 == MEM12); // used next because it's a perfect match + CHECK (m7 == MEM33); // this is a bad match, but it's the one left with the largest score + CHECK (m8 == MEM32); // the next best score is used to satisfy this request for SIZ20 + CHECK (m9 == MEM31); // only one left (which has the worst score because it was never used yet) + CHECK (watch(pool).cntFree() == 0); + CHECK (watch(pool).size() == 5); + + pool.reAdd (m8); + pool.reAdd (m7); + pool.reAdd (m6); + pool.reAdd (m5); + CHECK (watch(pool).cntFree() == 4); + CHECK (watch(pool).getScore(MEM11) == 15); // got +5 score because the match quality was only 50% (SIZ10 but only 5 needed) + CHECK (watch(pool).getScore(MEM12) == 10); // this one was new, but got a +10 score due to the perfect match + CHECK (watch(pool).getScore(MEM31) == 4); // this had -1 score but got +6 due to 60% match quality (SIZ30 but SIZ20 requested) + CHECK (watch(pool).getScore(MEM32) == 9); // previous score was +3 and it likewise got +6 + CHECK (watch(pool).getScore(MEM33) == 6); // this had also +3 and was first in the list, but got only +3 (SIZ30 used to satisfy SIZ10) + + // entries removed from pool... + lib::IterQueue returned; + auto returnAlloc = [&](Buff* mem, size_t){ returned.feed (size_t(mem)); }; + + // perform a rather aggressive clean-up + cnt = pool.cleanup(0.7 ,returnAlloc); // max score is 15, so anything below 0.7*15 ≡ 10 will be expunged + CHECK (cnt == 3); + CHECK (join(returned) == "12, 33, 32"_expect); // MEM12, MEM33 and MEM32 returned, since their score as <= 10 + CHECK (watch(pool).cntFree() == 1); + CHECK (watch(pool).size() == 2); + + CHECK (not watch(pool).isFree(MEM31)); // Note: MEM31 is still in use and thus remains in pool, even with that low score of 4 + + CHECK (watch(pool).getScore(MEM11) == 5); // Scores were reduced accordingly, cutting away a the »kill level« of +10 + CHECK (watch(pool).getScore(MEM31) == -6); + + pool.reAdd (MEM31); + auto [mX,sX] = pool.retrieve (SIZ20); + CHECK (mX == MEM31); + CHECK (sX == SIZ30); + pool.reAdd (MEM31); + CHECK (watch(pool).cntFree() == 2); + CHECK (watch(pool).getScore(MEM11) == 3); // was penalised with -2 since MEM11 was too small to satisfy a SIZ20 request + CHECK (watch(pool).getScore(MEM31) == 0); // while MEM31 got a score of +6 for a usage with 60% match quality (SIZ30 used for SIZ20) + + // shut down the pool... + cnt = pool.cleanup(1.0 ,returnAlloc); + CHECK (cnt == 2); + CHECK (join(returned) == "12, 33, 32, 31, 11"_expect); + CHECK (pool.empty()); } }; diff --git a/wiki/thinkPad.ichthyo.mm b/wiki/thinkPad.ichthyo.mm index a98bdda28..fb4717fbb 100644 --- a/wiki/thinkPad.ichthyo.mm +++ b/wiki/thinkPad.ichthyo.mm @@ -145442,7 +145442,7 @@ StM_bind(Builder<R1> b1, Extension<R1,R2> extension) - + @@ -145489,27 +145489,28 @@ StM_bind(Builder<R1> b1, Extension<R1,R2> extension) - - + + + - + - + - + - + - + @@ -145525,7 +145526,7 @@ StM_bind(Builder<R1> b1, Extension<R1,R2> extension) - + @@ -145700,7 +145701,7 @@ StM_bind(Builder<R1> b1, Extension<R1,R2> extension) - + @@ -145826,11 +145827,21 @@ StM_bind(Builder<R1> b1, Extension<R1,R2> extension) - - - - + + + + + + + + + + + + + + @@ -145841,7 +145852,7 @@ StM_bind(Builder<R1> b1, Extension<R1,R2> extension) - + @@ -145874,8 +145885,8 @@ StM_bind(Builder<R1> b1, Extension<R1,R2> extension) - - + + @@ -145927,23 +145938,20 @@ StM_bind(Builder<R1> b1, Extension<R1,R2> extension) - - + + - - - +

denn sonst hält nach einiger Zeit jeder Pool eine Menge selten genutzter Buffer, obwohl man mit einem Bruchteil davon auch über die Runden kommen könnte

- -
+
@@ -145954,16 +145962,13 @@ StM_bind(Builder<R1> b1, Extension<R1,R2> extension) - - - +

wobei dieser Prozentsatz aus dem degree - Parameter abgeleittet wird (mit umgekehrter Orientierung)

- -
+
@@ -145973,7 +145978,9 @@ StM_bind(Builder<R1> b1, Extension<R1,R2> extension) - + + + @@ -145989,40 +145996,31 @@ StM_bind(Builder<R1> b1, Extension<R1,R2> extension) - - - +

sonst besteht die Gefahr, sehr große Blöcke für kleine Anforderungen zu verschwenden und dann nachfolgend größere Allokationen anforern zu müssen, obwohl eigentlich genügend Storage lokal schon da wäre

- -
+
- - - +

Schwierige Entscheidung: Laufzeit-Trend ist nicht bekannt

- -
+ - - - +

Insofern weiß man nicht einmal, ob der Aufwand hier bedeutsam ist, oder man einfach einen Vector sortieren könnte

- -
+
@@ -146031,29 +146029,23 @@ StM_bind(Builder<R1> b1, Extension<R1,R2> extension) - - - +

Das Problem hierbei ist, daß wir dafür bereits Aufwand treiben müssen, der u.U den Aufwand für das Sortieren übersteigt

- -
+
- - - +

Da wir letztlich sowiso einen Pool-Allokator haben, kann es durchaus sinnvoll sein, neue Allokationen zu holen, und die unpassenden alten Allokationen zeitnah aber asynchron zum Pool zurückzuschicken

- -
+
@@ -146070,8 +146062,8 @@ StM_bind(Builder<R1> b1, Extension<R1,R2> extension)
- - + + @@ -146082,14 +146074,21 @@ StM_bind(Builder<R1> b1, Extension<R1,R2> extension) - - + + + + + + + + + + -