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) - - + + + + + + + + + + -