From 365395185d7caf401b3f53eedc48252c86c313a5 Mon Sep 17 00:00:00 2001 From: Joseph Birkner Date: Tue, 28 Jul 2026 19:12:16 +0200 Subject: [PATCH 1/4] Add min function --- docs/simfil-language.md | 12 +++++++ include/simfil/function.h | 11 ++++++ src/environment.cpp | 1 + src/function.cpp | 72 +++++++++++++++++++++++++++++++++++++++ test/simfil.cpp | 8 +++++ 5 files changed, 104 insertions(+) diff --git a/docs/simfil-language.md b/docs/simfil-language.md index fc0941af..3c074b74 100644 --- a/docs/simfil-language.md +++ b/docs/simfil-language.md @@ -360,6 +360,18 @@ sum(range(1, 10)..., $sum * $val, 1) => 3628800 sum(list, #$sum > 0 and $sum + ', ' + $val or $val, '') ``` +#### `min(...)` + +Returns the smallest non-null value produced by its arguments. Arguments may +produce several values; an explicit final argument can provide a fallback when +the preceding paths are absent. + +*Example* +``` +min(8, 3, 5) => 3 +min(optional.rank, 7) => 7 # when optional.rank is absent +``` + #### `keys(object)` diff --git a/include/simfil/function.h b/include/simfil/function.h index 19265b1d..c8ea4e9f 100644 --- a/include/simfil/function.h +++ b/include/simfil/function.h @@ -124,6 +124,17 @@ class SumFn : public Function auto eval(Context, const Value&, const std::vector&, const ResultFn&) const -> tl::expected override; }; +class MinFn : public Function +{ +public: + static MinFn Fn; + + MinFn(); + + auto ident() const -> const FnInfo& override; + auto eval(Context, const Value&, const std::vector&, const ResultFn&) const -> tl::expected override; +}; + class KeysFn : public Function { public: diff --git a/src/environment.cpp b/src/environment.cpp index 279a9656..59eacac5 100644 --- a/src/environment.cpp +++ b/src/environment.cpp @@ -19,6 +19,7 @@ Environment::Environment(std::shared_ptr strings) functions["split"] = &SplitFn::Fn; functions["select"] = &SelectFn::Fn; functions["sum"] = &SumFn::Fn; + functions["min"] = &MinFn::Fn; functions["keys"] = &KeysFn::Fn; functions["trace"] = &TraceFn::Fn; functions["re"] = &ReFn::Fn; diff --git a/src/function.cpp b/src/function.cpp index 9a57fd41..13329d50 100644 --- a/src/function.cpp +++ b/src/function.cpp @@ -538,6 +538,78 @@ auto SumFn::eval(Context ctx, const Value& val, const std::vector& args return res(ctx, sum); } +MinFn MinFn::Fn; +MinFn::MinFn() = default; + +auto MinFn::ident() const -> const FnInfo& +{ + static const FnInfo info{ + "min", + "Returns the smallest non-null value produced by its arguments.", + "min(expr...) -> " + }; + return info; +} + +auto MinFn::eval( + Context ctx, + const Value& val, + const std::vector& args, + const ResultFn& res) const -> tl::expected +{ + if (args.empty()) { + return tl::unexpected( + Error::InvalidArguments, + "function 'min' expects at least one argument"); + } + + std::optional minimum; + bool compilationUndef = false; + for (auto const& arg : args) { + auto argResult = arg->eval( + ctx, + val, + LambdaResultFn( + [&](Context valueContext, Value&& value) + -> tl::expected + { + if (value.isa(ValueType::Undef)) { + compilationUndef = + compilationUndef || + valueContext.phase == + Context::Phase::Compilation; + return Result::Continue; + } + if (value.isa(ValueType::Null)) { + return Result::Continue; + } + if (!minimum) { + minimum = std::move(value); + return Result::Continue; + } + + auto less = + BinaryOperatorDispatcher::dispatch( + value, + *minimum); + TRY_EXPECTED(less); + if (less->as()) { + minimum = std::move(value); + } + return Result::Continue; + })); + TRY_EXPECTED(argResult); + } + + if (compilationUndef) { + return res(ctx, Value::undef()); + } + if (!minimum) { + return res(ctx, Value::null()); + } + return res(ctx, std::move(*minimum)); +} + KeysFn KeysFn::Fn; KeysFn::KeysFn() = default; diff --git a/test/simfil.cpp b/test/simfil.cpp index 040a5808..e7e43716 100644 --- a/test/simfil.cpp +++ b/test/simfil.cpp @@ -520,6 +520,14 @@ TEST_CASE("Model Functions", "[yaml.model-functions]") { REQUIRE_PANIC("sum(range(1, 10)..., panic())"); REQUIRE_PANIC("sum(range(1, 10)..., 0, panic())"); } + + SECTION("Test min(...)") { + REQUIRE_RESULT("min(4, 6, 7)", "4"); + REQUIRE_RESULT("min(arr(8, 3, 5))", "3"); + REQUIRE_RESULT("min(null, 4)", "4"); + REQUIRE_RESULT("min(null)", "null"); + REQUIRE_PANIC("min(panic())"); + } SECTION("Count non-false values of arr(...)") { REQUIRE_RESULT("count(arr(null, null))", "0"); REQUIRE_RESULT("count(arr(true, null))", "1"); From f33f6c3293eadb29b7957f98a7b9a57a99409acf Mon Sep 17 00:00:00 2001 From: Joseph Birkner Date: Tue, 28 Jul 2026 19:54:25 +0200 Subject: [PATCH 2/4] Add max and strengthen extrema coverage --- docs/simfil-language.md | 27 ++++++-- include/simfil/function.h | 13 ++++ src/environment.cpp | 1 + src/function.cpp | 132 ++++++++++++++++++++++++-------------- test/completion.cpp | 2 + test/simfil.cpp | 21 ++++++ 6 files changed, 143 insertions(+), 53 deletions(-) diff --git a/docs/simfil-language.md b/docs/simfil-language.md index 3c074b74..772dee30 100644 --- a/docs/simfil-language.md +++ b/docs/simfil-language.md @@ -360,11 +360,15 @@ sum(range(1, 10)..., $sum * $val, 1) => 3628800 sum(list, #$sum > 0 and $sum + ', ' + $val or $val, '') ``` -#### `min(...)` +#### `min(values...)` -Returns the smallest non-null value produced by its arguments. Arguments may -produce several values; an explicit final argument can provide a fallback when -the preceding paths are absent. +Returns the smallest non-null value produced by its arguments. Missing and null +values are ignored, and arguments may produce several values. If no argument +produces a value, the result is null. Values are compared with the ordinary `<` +operator and should therefore be mutually comparable. + +A final literal participates in the comparison like every other value. It can +therefore provide both a fallback for missing paths and an upper bound. *Example* ``` @@ -372,6 +376,21 @@ min(8, 3, 5) => 3 min(optional.rank, 7) => 7 # when optional.rank is absent ``` +#### `max(values...)` + +Returns the largest non-null value produced by its arguments. Missing and null +values are ignored, and arguments may produce several values. If no argument +produces a value, the result is null. Values are compared with the ordinary `>` +operator and should therefore be mutually comparable. + +A final literal can provide both a fallback for missing paths and a lower +bound. + +*Example* +``` +max(8, 3, 5) => 8 +max(optional.rank, 0) => 0 # when optional.rank is absent +``` #### `keys(object)` diff --git a/include/simfil/function.h b/include/simfil/function.h index c8ea4e9f..dfb29df2 100644 --- a/include/simfil/function.h +++ b/include/simfil/function.h @@ -124,6 +124,7 @@ class SumFn : public Function auto eval(Context, const Value&, const std::vector&, const ResultFn&) const -> tl::expected override; }; +/** Returns the smallest non-null value emitted by its argument expressions. */ class MinFn : public Function { public: @@ -135,6 +136,18 @@ class MinFn : public Function auto eval(Context, const Value&, const std::vector&, const ResultFn&) const -> tl::expected override; }; +/** Returns the largest non-null value emitted by its argument expressions. */ +class MaxFn : public Function +{ +public: + static MaxFn Fn; + + MaxFn(); + + auto ident() const -> const FnInfo& override; + auto eval(Context, const Value&, const std::vector&, const ResultFn&) const -> tl::expected override; +}; + class KeysFn : public Function { public: diff --git a/src/environment.cpp b/src/environment.cpp index 59eacac5..74ed413e 100644 --- a/src/environment.cpp +++ b/src/environment.cpp @@ -20,6 +20,7 @@ Environment::Environment(std::shared_ptr strings) functions["select"] = &SelectFn::Fn; functions["sum"] = &SumFn::Fn; functions["min"] = &MinFn::Fn; + functions["max"] = &MaxFn::Fn; functions["keys"] = &KeysFn::Fn; functions["trace"] = &TraceFn::Fn; functions["re"] = &ReFn::Fn; diff --git a/src/function.cpp b/src/function.cpp index 13329d50..6a00fc6c 100644 --- a/src/function.cpp +++ b/src/function.cpp @@ -128,6 +128,68 @@ auto boolify(const Value& v) -> bool return false; return UnaryOperatorDispatcher::dispatch(v).value_or(Value::f()).as(); } + +/** Reduce all defined, non-null argument values using the supplied comparison operator. */ +template +auto evalExtremum( + std::string_view functionName, + Context ctx, + const Value& val, + const std::vector& args, + const ResultFn& res) -> tl::expected +{ + if (args.empty()) { + return tl::unexpected( + Error::InvalidArguments, + fmt::format("function '{}' expects at least one argument", functionName)); + } + + std::optional extremum; + bool compilationUndef = false; + for (auto const& arg : args) { + auto argResult = arg->eval( + ctx, + val, + LambdaResultFn( + [&](Context valueContext, Value&& value) + -> tl::expected + { + if (value.isa(ValueType::Undef)) { + // Preserve runtime-dependent expressions instead of folding around them. + compilationUndef = compilationUndef || + valueContext.phase == Context::Phase::Compilation; + return Result::Continue; + } + // Null represents no candidate, just like an expression that emits no value. + if (value.isa(ValueType::Null)) { + return Result::Continue; + } + if (!extremum) { + extremum = std::move(value); + return Result::Continue; + } + + auto candidateWins = + BinaryOperatorDispatcher::dispatch( + value, + *extremum); + TRY_EXPECTED(candidateWins); + if (candidateWins->template as()) { + extremum = std::move(value); + } + return Result::Continue; + })); + TRY_EXPECTED(argResult); + } + + if (compilationUndef) { + return res(ctx, Value::undef()); + } + if (!extremum) { + return res(ctx, Value::null()); + } + return res(ctx, std::move(*extremum)); +} } CountFn CountFn::Fn; @@ -546,7 +608,7 @@ auto MinFn::ident() const -> const FnInfo& static const FnInfo info{ "min", "Returns the smallest non-null value produced by its arguments.", - "min(expr...) -> " + "min(values...) -> " }; return info; } @@ -557,57 +619,29 @@ auto MinFn::eval( const std::vector& args, const ResultFn& res) const -> tl::expected { - if (args.empty()) { - return tl::unexpected( - Error::InvalidArguments, - "function 'min' expects at least one argument"); - } + return evalExtremum("min", ctx, val, args, res); +} - std::optional minimum; - bool compilationUndef = false; - for (auto const& arg : args) { - auto argResult = arg->eval( - ctx, - val, - LambdaResultFn( - [&](Context valueContext, Value&& value) - -> tl::expected - { - if (value.isa(ValueType::Undef)) { - compilationUndef = - compilationUndef || - valueContext.phase == - Context::Phase::Compilation; - return Result::Continue; - } - if (value.isa(ValueType::Null)) { - return Result::Continue; - } - if (!minimum) { - minimum = std::move(value); - return Result::Continue; - } +MaxFn MaxFn::Fn; +MaxFn::MaxFn() = default; - auto less = - BinaryOperatorDispatcher::dispatch( - value, - *minimum); - TRY_EXPECTED(less); - if (less->as()) { - minimum = std::move(value); - } - return Result::Continue; - })); - TRY_EXPECTED(argResult); - } +auto MaxFn::ident() const -> const FnInfo& +{ + static const FnInfo info{ + "max", + "Returns the largest non-null value produced by its arguments.", + "max(values...) -> " + }; + return info; +} - if (compilationUndef) { - return res(ctx, Value::undef()); - } - if (!minimum) { - return res(ctx, Value::null()); - } - return res(ctx, std::move(*minimum)); +auto MaxFn::eval( + Context ctx, + const Value& val, + const std::vector& args, + const ResultFn& res) const -> tl::expected +{ + return evalExtremum("max", ctx, val, args, res); } KeysFn KeysFn::Fn; diff --git a/test/completion.cpp b/test/completion.cpp index 911c0568..6233effa 100644 --- a/test/completion.cpp +++ b/test/completion.cpp @@ -151,6 +151,8 @@ TEST_CASE("CompleteString", "[completion.string-const]") { TEST_CASE("Complete Function", "[completion.function]") { EXPECT_COMPLETION("cou", {}, "count"); + EXPECT_COMPLETION("ma", {}, "max"); + EXPECT_COMPLETION("mi", {}, "min"); EXPECT_COMPLETION("su", {}, "sum"); } diff --git a/test/simfil.cpp b/test/simfil.cpp index e7e43716..f173e4f4 100644 --- a/test/simfil.cpp +++ b/test/simfil.cpp @@ -524,9 +524,30 @@ TEST_CASE("Model Functions", "[yaml.model-functions]") { SECTION("Test min(...)") { REQUIRE_RESULT("min(4, 6, 7)", "4"); REQUIRE_RESULT("min(arr(8, 3, 5))", "3"); + REQUIRE_RESULT("min(1.5, 1)", "1"); + REQUIRE_RESULT("min('beta', 'alpha')", "alpha"); + REQUIRE_RESULT("min(number, 7)", "7"); + REQUIRE_RESULT("min(missing.value, 7)", "7"); + REQUIRE_RESULT("min(missing.value)", "null"); REQUIRE_RESULT("min(null, 4)", "4"); REQUIRE_RESULT("min(null)", "null"); + REQUIRE_ERROR("min()"); REQUIRE_PANIC("min(panic())"); + REQUIRE_PANIC("min(4, panic())"); + } + SECTION("Test max(...)") { + REQUIRE_RESULT("max(4, 6, 7)", "7"); + REQUIRE_RESULT("max(arr(8, 3, 5))", "8"); + REQUIRE_RESULT("max(1.5, 1)", "1.500000"); + REQUIRE_RESULT("max('alpha', 'beta')", "beta"); + REQUIRE_RESULT("max(number, 7)", "123"); + REQUIRE_RESULT("max(missing.value, 7)", "7"); + REQUIRE_RESULT("max(missing.value)", "null"); + REQUIRE_RESULT("max(null, 4)", "4"); + REQUIRE_RESULT("max(null)", "null"); + REQUIRE_ERROR("max()"); + REQUIRE_PANIC("max(panic())"); + REQUIRE_PANIC("max(4, panic())"); } SECTION("Count non-false values of arr(...)") { REQUIRE_RESULT("count(arr(null, null))", "0"); From 254886cb9e3eb47f9fc8cfa1359f522e6e89a89f Mon Sep 17 00:00:00 2001 From: Joseph Birkner Date: Tue, 28 Jul 2026 20:00:46 +0200 Subject: [PATCH 3/4] Deduplicate extrema tests --- test/simfil.cpp | 59 +++++++++++++++++++++++++++---------------------- 1 file changed, 32 insertions(+), 27 deletions(-) diff --git a/test/simfil.cpp b/test/simfil.cpp index f173e4f4..aae29459 100644 --- a/test/simfil.cpp +++ b/test/simfil.cpp @@ -521,33 +521,38 @@ TEST_CASE("Model Functions", "[yaml.model-functions]") { REQUIRE_PANIC("sum(range(1, 10)..., 0, panic())"); } - SECTION("Test min(...)") { - REQUIRE_RESULT("min(4, 6, 7)", "4"); - REQUIRE_RESULT("min(arr(8, 3, 5))", "3"); - REQUIRE_RESULT("min(1.5, 1)", "1"); - REQUIRE_RESULT("min('beta', 'alpha')", "alpha"); - REQUIRE_RESULT("min(number, 7)", "7"); - REQUIRE_RESULT("min(missing.value, 7)", "7"); - REQUIRE_RESULT("min(missing.value)", "null"); - REQUIRE_RESULT("min(null, 4)", "4"); - REQUIRE_RESULT("min(null)", "null"); - REQUIRE_ERROR("min()"); - REQUIRE_PANIC("min(panic())"); - REQUIRE_PANIC("min(4, panic())"); - } - SECTION("Test max(...)") { - REQUIRE_RESULT("max(4, 6, 7)", "7"); - REQUIRE_RESULT("max(arr(8, 3, 5))", "8"); - REQUIRE_RESULT("max(1.5, 1)", "1.500000"); - REQUIRE_RESULT("max('alpha', 'beta')", "beta"); - REQUIRE_RESULT("max(number, 7)", "123"); - REQUIRE_RESULT("max(missing.value, 7)", "7"); - REQUIRE_RESULT("max(missing.value)", "null"); - REQUIRE_RESULT("max(null, 4)", "4"); - REQUIRE_RESULT("max(null)", "null"); - REQUIRE_ERROR("max()"); - REQUIRE_PANIC("max(panic())"); - REQUIRE_PANIC("max(4, panic())"); + SECTION("Test extrema") { + struct ExtremumExpectations { + std::string_view name; + std::string_view scalarResult; + std::string_view arrayResult; + std::string_view numericResult; + std::string_view stringResult; + std::string_view modelResult; + }; + + const auto call = [](std::string_view name, std::string_view arguments) { + return std::string(name) + "(" + std::string(arguments) + ")"; + }; + + for (const auto& expectation : { + ExtremumExpectations{"min", "4", "3", "1", "alpha", "7"}, + ExtremumExpectations{"max", "7", "8", "1.500000", "beta", "123"}, + }) { + CAPTURE(expectation.name); + REQUIRE_RESULT(call(expectation.name, "4, 6, 7"), expectation.scalarResult); + REQUIRE_RESULT(call(expectation.name, "arr(8, 3, 5)"), expectation.arrayResult); + REQUIRE_RESULT(call(expectation.name, "1.5, 1"), expectation.numericResult); + REQUIRE_RESULT(call(expectation.name, "'beta', 'alpha'"), expectation.stringResult); + REQUIRE_RESULT(call(expectation.name, "number, 7"), expectation.modelResult); + REQUIRE_RESULT(call(expectation.name, "missing.value, 7"), "7"); + REQUIRE_RESULT(call(expectation.name, "missing.value"), "null"); + REQUIRE_RESULT(call(expectation.name, "null, 4"), "4"); + REQUIRE_RESULT(call(expectation.name, "null"), "null"); + REQUIRE_ERROR(call(expectation.name, "")); + REQUIRE_PANIC(call(expectation.name, "panic()")); + REQUIRE_PANIC(call(expectation.name, "4, panic()")); + } } SECTION("Count non-false values of arr(...)") { REQUIRE_RESULT("count(arr(null, null))", "0"); From 95a0519aa048464b0c210ef758456c0fa28d9c05 Mon Sep 17 00:00:00 2001 From: Joseph Birkner Date: Fri, 7 Aug 2026 16:25:43 +0200 Subject: [PATCH 4/4] Expose retained model memory usage --- CMakeLists.txt | 1 + include/simfil/model/arena.h | 26 ++++++++++++++++++++ include/simfil/model/column.h | 19 +++++++++++++++ include/simfil/model/memory.h | 39 ++++++++++++++++++++++++++++++ include/simfil/model/model.h | 28 +++++++++++++++++++++ include/simfil/model/string-pool.h | 4 +++ src/model/model.cpp | 24 ++++++++++++++++++ src/model/string-pool.cpp | 32 ++++++++++++++++++++++++ test/arena.cpp | 18 ++++++++++++++ test/simfil.cpp | 16 ++++++++++++ 10 files changed, 207 insertions(+) create mode 100644 include/simfil/model/memory.h diff --git a/CMakeLists.txt b/CMakeLists.txt index f0db16a0..fcd4dd54 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -99,6 +99,7 @@ target_sources(simfil PUBLIC include/simfil/model/arena.h include/simfil/model/column.h + include/simfil/model/memory.h include/simfil/model/string-pool.h include/simfil/model/model.h include/simfil/model/nodes.h diff --git a/include/simfil/model/arena.h b/include/simfil/model/arena.h index 36af661c..7bf95663 100644 --- a/include/simfil/model/arena.h +++ b/include/simfil/model/arena.h @@ -265,6 +265,32 @@ class ArrayArena return result + singletonBytes; } + /** + * Return compact logical payload and all retained arena backing capacity. + * + * Unlike `byte_size()`, allocated bytes include unused array capacity, + * continuation chunks, and storage retained after clear operations. + */ + [[nodiscard]] MemoryUsage memory_usage() const + { + #ifdef ARRAY_ARENA_THREAD_SAFE + std::shared_lock guard(lock_); + #endif + MemoryUsage result; + result.logicalBytes = byte_size(); + result.allocatedBytes = + heads_.memory_usage().allocatedBytes + + continuations_.memory_usage().allocatedBytes + + data_.memory_usage().allocatedBytes + + singletonValues_.memory_usage().allocatedBytes + + singletonOccupied_.memory_usage().allocatedBytes; + if (compactHeads_) { + result.allocatedBytes += compactHeads_->memory_usage().allocatedBytes; + } + result.allocatedBytes = std::max(result.logicalBytes, result.allocatedBytes); + return result; + } + /** * Returns a reference to the element at the specified index in the array. * diff --git a/include/simfil/model/column.h b/include/simfil/model/column.h index e7a38ed4..234199f4 100644 --- a/include/simfil/model/column.h +++ b/include/simfil/model/column.h @@ -21,6 +21,8 @@ #include #include +#include "simfil/model/memory.h" + namespace simfil { @@ -293,6 +295,17 @@ class ModelColumn return values_.size() * sizeof(value_type); } + /** Return live payload and retained backing capacity for this column. */ + [[nodiscard]] MemoryUsage memory_usage() const + { + auto const logicalBytes = byte_size(); + auto const allocatedBytes = values_.capacity() * sizeof(value_type); + return { + logicalBytes, + std::max(logicalBytes, allocatedBytes), + }; + } + bool empty() const { return values_.empty(); } void clear() { values_.clear(); } @@ -658,6 +671,12 @@ class ModelColumn, T_RecordsPerPage, T_StoragePolicy> return first_values_.byte_size() + second_values_.byte_size(); } + /** Return combined live payload and retained capacity of both split columns. */ + [[nodiscard]] MemoryUsage memory_usage() const + { + return first_values_.memory_usage() + second_values_.memory_usage(); + } + [[nodiscard]] bool empty() const { return size() == 0; } void clear() diff --git a/include/simfil/model/memory.h b/include/simfil/model/memory.h new file mode 100644 index 00000000..88fa7378 --- /dev/null +++ b/include/simfil/model/memory.h @@ -0,0 +1,39 @@ +// Copyright (c) Navigation Data Standard e.V. - See "LICENSE" file. + +#pragma once + +#include + +namespace simfil +{ + +/** + * Capacity-oriented memory measurement for one owned storage component. + * + * `logicalBytes` describes live payload, while `allocatedBytes` describes the + * retained backing capacity. Container objects and allocator bookkeeping are + * intentionally excluded, so allocated bytes form a stable lower bound rather + * than pretending to equal process-resident memory. + */ +struct MemoryUsage +{ + std::size_t logicalBytes = 0; + std::size_t allocatedBytes = 0; + + /** Add another independently owned storage component. */ + MemoryUsage& operator+=(MemoryUsage const& other) + { + logicalBytes += other.logicalBytes; + allocatedBytes += other.allocatedBytes; + return *this; + } +}; + +/** Combine two independently owned storage components. */ +inline MemoryUsage operator+(MemoryUsage left, MemoryUsage const& right) +{ + left += right; + return left; +} + +} // namespace simfil diff --git a/include/simfil/model/model.h b/include/simfil/model/model.h index 0cc4ca39..c5859cbe 100644 --- a/include/simfil/model/model.h +++ b/include/simfil/model/model.h @@ -2,6 +2,7 @@ #pragma once #include "simfil/model/string-pool.h" +#include "simfil/model/memory.h" #include "simfil/model/schema.h" #include "simfil/byte-array.h" #include "tl/expected.hpp" @@ -305,6 +306,33 @@ class ModelPool : public Model [[nodiscard]] SerializationSizeStats serializationSizeStats() const; + /** Capacity-oriented memory breakdown of the generic model-pool columns. */ + struct MemoryUsageStats + { + MemoryUsage implementation; + MemoryUsage roots; + MemoryUsage int64Values; + MemoryUsage doubleValues; + MemoryUsage stringData; + MemoryUsage stringRanges; + MemoryUsage byteArrayRanges; + MemoryUsage objectMembers; + MemoryUsage objectSchemas; + MemoryUsage arrayMembers; + MemoryUsage arraySchemas; + + /** Sum all independently owned model-pool storage. */ + [[nodiscard]] MemoryUsage total() const + { + return implementation + roots + int64Values + doubleValues + stringData + + stringRanges + byteArrayRanges + objectMembers + objectSchemas + + arrayMembers + arraySchemas; + } + }; + + /** Return live payload and retained capacity for generic model-pool storage. */ + [[nodiscard]] MemoryUsageStats memoryUsageStats() const; + #if defined(SIMFIL_WITH_MODEL_JSON) /** JSON Serialization */ virtual nlohmann::json toJson() const; diff --git a/include/simfil/model/string-pool.h b/include/simfil/model/string-pool.h index e7fa0203..5da7267a 100644 --- a/include/simfil/model/string-pool.h +++ b/include/simfil/model/string-pool.h @@ -14,6 +14,7 @@ #include #include "simfil/error.h" +#include "simfil/model/memory.h" namespace simfil { @@ -69,6 +70,9 @@ struct StringPool size_t hits() const; size_t misses() const; + /** Return interned string payload and estimated retained container capacity. */ + [[nodiscard]] MemoryUsage memoryUsage() const; + /// Add a static key-string mapping - Warning: Not thread-safe. void addStaticKey(StringId id, std::string const& value); diff --git a/src/model/model.cpp b/src/model/model.cpp index d21ab1f1..07622ace 100644 --- a/src/model/model.cpp +++ b/src/model/model.cpp @@ -517,6 +517,30 @@ ModelPool::SerializationSizeStats ModelPool::serializationSizeStats() const return stats; } +ModelPool::MemoryUsageStats ModelPool::memoryUsageStats() const +{ + MemoryUsageStats stats; + stats.implementation = {sizeof(Impl), sizeof(Impl)}; + stats.roots = impl_->columns_.roots_.memory_usage(); + stats.int64Values = impl_->columns_.i64_.memory_usage(); + stats.doubleValues = impl_->columns_.double_.memory_usage(); + stats.stringData = { + impl_->columns_.stringData_.size(), + impl_->columns_.stringData_.capacity(), + }; + stats.stringRanges = impl_->columns_.strings_.memory_usage(); + stats.byteArrayRanges = impl_->columns_.byteArrays_.memory_usage(); + stats.objectMembers = impl_->columns_.objectMemberArrays_.memory_usage(); + stats.objectSchemas = + impl_->columns_.objectSchemas_.memory_usage() + + impl_->columns_.objectSingletonSchemas_.memory_usage(); + stats.arrayMembers = impl_->columns_.arrayMemberArrays_.memory_usage(); + stats.arraySchemas = + impl_->columns_.arraySchemas_.memory_usage() + + impl_->columns_.arraySingletonSchemas_.memory_usage(); + return stats; +} + std::optional ModelPool::lookupStringId(const simfil::StringId id) const { return impl_->strings_->resolve(id); diff --git a/src/model/string-pool.cpp b/src/model/string-pool.cpp index ccce9e6c..cf9c0b62 100644 --- a/src/model/string-pool.cpp +++ b/src/model/string-pool.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include /** @@ -177,6 +178,37 @@ size_t StringPool::misses() const return cacheMisses_; } +MemoryUsage StringPool::memoryUsage() const +{ + std::shared_lock lock(stringStoreMutex_); + + MemoryUsage result; + result.logicalBytes = static_cast(byteSize_.load()); + + // deque does not expose capacity. Count occupied string objects plus any + // character buffers which live outside those objects; block slack remains + // part of the documented lower-bound gap. + result.allocatedBytes = storedStrings_.size() * sizeof(std::string); + for (auto const& string : storedStrings_) { + auto const objectBegin = reinterpret_cast(&string); + auto const objectEnd = objectBegin + sizeof(string); + auto const data = reinterpret_cast(string.data()); + if (data < objectBegin || data >= objectEnd) { + result.allocatedBytes += string.capacity() + 1; + } + } + + // Unordered-map buckets and occupied values are stable, useful lower-bound + // estimates; implementation-specific node and allocator overhead is omitted. + result.allocatedBytes += + idForString_.bucket_count() * sizeof(void*) + + idForString_.size() * sizeof(decltype(idForString_)::value_type) + + stringForId_.bucket_count() * sizeof(void*) + + stringForId_.size() * sizeof(decltype(stringForId_)::value_type); + result.allocatedBytes = std::max(result.logicalBytes, result.allocatedBytes); + return result; +} + void StringPool::addStaticKey(StringId id, const std::string& value) { std::unique_lock lock(stringStoreMutex_); diff --git a/test/arena.cpp b/test/arena.cpp index 099b8d86..5094b8a6 100644 --- a/test/arena.cpp +++ b/test/arena.cpp @@ -88,6 +88,24 @@ TEST_CASE("ArrayArena clear and shrink_to_fit", "[ArrayArena]") { } } +TEST_CASE("ModelColumn and ArrayArena report retained capacity", "[memory][ModelColumn][ArrayArena]") +{ + ModelColumn column; + column.reserve(17); + column.push_back(7); + auto const columnUsage = column.memory_usage(); + REQUIRE(columnUsage.logicalBytes == sizeof(uint32_t)); + REQUIRE(columnUsage.allocatedBytes >= 17 * sizeof(uint32_t)); + + ArrayArena arena; + auto const values = arena.new_array(16); + arena.push_back(values, 1); + auto const arenaUsage = arena.memory_usage(); + REQUIRE(arenaUsage.logicalBytes >= sizeof(uint32_t)); + REQUIRE(arenaUsage.allocatedBytes >= 16 * sizeof(uint32_t)); + REQUIRE(arenaUsage.allocatedBytes >= arenaUsage.logicalBytes); +} + TEST_CASE("ArrayArena multiple arrays", "[ArrayArena]") { ArrayArena arena; std::vector> expected = { diff --git a/test/simfil.cpp b/test/simfil.cpp index aae29459..8f3b584a 100644 --- a/test/simfil.cpp +++ b/test/simfil.cpp @@ -769,6 +769,22 @@ TEST_CASE("StringPool copy owns lookup views", "[string-pool]") REQUIRE(copy->get("owned-dynamic-field") == *id); } +TEST_CASE("Model and string pools report retained memory", "[memory][model][string-pool]") +{ + auto pool = std::make_shared(); + auto object = pool->newObject(8); + object->addField("long-enough-field-name-to-allocate", "long-enough-value-to-allocate"); + pool->addRoot(object); + + auto const modelUsage = pool->memoryUsageStats().total(); + REQUIRE(modelUsage.logicalBytes > 0); + REQUIRE(modelUsage.allocatedBytes >= modelUsage.logicalBytes); + + auto const stringUsage = pool->strings()->memoryUsage(); + REQUIRE(stringUsage.logicalBytes >= std::string_view("long-enough-field-name-to-allocate").size()); + REQUIRE(stringUsage.allocatedBytes >= stringUsage.logicalBytes); +} + TEST_CASE("Exception Handler", "[exception]") { bool handlerCalled = false;