From 2ec46c5c3475899dbb43c6f2f33a94e9636b577e Mon Sep 17 00:00:00 2001 From: Serijo Date: Tue, 4 Aug 2026 00:35:17 +0300 Subject: [PATCH 1/5] FrameConfig integrated. --- benchmarks/benchmark.cpp | 27 +++-- include/messageframe/HybridMessageMap.hpp | 4 +- include/messageframe/MessageFrame.hpp | 20 +++- include/messageframe/Structures.hpp | 14 +++ src/HybridMessageMap.cpp | 35 +++++- tests/test_hybrid_map.cpp | 132 ++++++++++++++++++++++ 6 files changed, 215 insertions(+), 17 deletions(-) diff --git a/benchmarks/benchmark.cpp b/benchmarks/benchmark.cpp index 7bcb949..6ac3cfb 100644 --- a/benchmarks/benchmark.cpp +++ b/benchmarks/benchmark.cpp @@ -40,15 +40,17 @@ void printParamCallback(std::string_view flat_key, const msgframe::ParameterValu struct BenchmarkConfig { - size_t iterations = 100'000; - size_t params_count = 150; + size_t iterations = 200'000; + size_t params_count = 4; + size_t reserve_hint = 0; // FrameConfig::initial_reserve, 0 = disabled }; void printUsage(const char* prog_name) { - std::cout << "Usage: " << prog_name << " [--iterations N] [--params N]\n" - << " --iterations N Number of message lifecycle iterations (default: 100000)\n" - << " --params N Number of parameters per message (default: 150)\n" - << " -h, --help Show this help message\n"; + std::cout << "Usage: " << prog_name << " [--iterations N] [--params N] [--reserve N]\n" + << " --iterations N Number of message lifecycle iterations (default: 200000)\n" + << " --params N Number of parameters per message (default: 2)\n" + << " --reserve N FrameConfig::initial_reserve hint (default: 0 = disabled)\n" + << " -h, --help Show this help message\n"; } BenchmarkConfig parseArgs(int argc, char** argv) { @@ -58,6 +60,8 @@ BenchmarkConfig parseArgs(int argc, char** argv) { cfg.iterations = static_cast(std::strtoull(argv[++i], nullptr, 10)); } else if (std::strcmp(argv[i], "--params") == 0 && i + 1 < argc) { cfg.params_count = static_cast(std::strtoull(argv[++i], nullptr, 10)); + } else if (std::strcmp(argv[i], "--reserve") == 0 && i + 1 < argc) { + cfg.reserve_hint = static_cast(std::strtoull(argv[++i], nullptr, 10)); } else if (std::strcmp(argv[i], "-h") == 0 || std::strcmp(argv[i], "--help") == 0) { printUsage(argv[0]); std::exit(0); @@ -133,9 +137,14 @@ void runBenchmark(const BenchmarkConfig& cfg) { serialization_buffer.reserve(32768); std::cout << "Running " << cfg.iterations << " iterations with " - << cfg.params_count << " parameters each...\n"; + << cfg.params_count << " parameters each" + << (cfg.reserve_hint > 0 ? " (reserve hint: " + std::to_string(cfg.reserve_hint) + ")" : "") + << "...\n"; std::cout << "(Zero-Allocation key injection via pre-allocated Key Pool)\n\n"; + msgframe::FrameConfig frame_cfg; + frame_cfg.initial_reserve = cfg.reserve_hint; + auto start_time = std::chrono::high_resolution_clock::now(); size_t total_bytes_processed = 0; @@ -157,7 +166,7 @@ void runBenchmark(const BenchmarkConfig& cfg) { for (size_t i = 0; i < cfg.iterations; ++i) { serialization_buffer.clear(); - msgframe::MessageFrame bench_msg(200, 7, 1, 2, i); + msgframe::MessageFrame bench_msg(200, 7, 1, 2, i, /*proto_version=*/1, /*msg_flags=*/0, frame_cfg); auto t0 = std::chrono::high_resolution_clock::now(); for (size_t p = 0; p < cfg.params_count; ++p) { @@ -217,6 +226,8 @@ void runBenchmark(const BenchmarkConfig& cfg) { std::cout << "Throughput: " << std::fixed << std::setprecision(2) << throughput_mb << " MB/sec\n"; std::cout << "Success Rate: " << (successful_deserializations == cfg.iterations ? "100% OK" : "ERROR") << "\n"; std::cout << "Avg Packed Size: " << (total_bytes_processed / cfg.iterations) << " bytes\n"; + std::cout << "Reserve hint: " << cfg.reserve_hint + << (cfg.reserve_hint > 0 ? " (FrameConfig applied)" : " (default lazy sizing)") << "\n"; std::cout << "sum_add: " << (sum_add / cfg.iterations) << " us\n"; std::cout << "sum_find: " << (sum_find / cfg.iterations) << " us (worst-case: last-inserted key)\n"; std::cout << "sum_serialize: " << (sum_serialize / cfg.iterations) << " us\n"; diff --git a/include/messageframe/HybridMessageMap.hpp b/include/messageframe/HybridMessageMap.hpp index 6bfc170..b03d8bb 100644 --- a/include/messageframe/HybridMessageMap.hpp +++ b/include/messageframe/HybridMessageMap.hpp @@ -29,6 +29,7 @@ namespace msgframe { static constexpr size_t SMALL_CAPACITY = 128; HybridMessageMap(); + explicit HybridMessageMap(const FrameConfig& config); ~HybridMessageMap() noexcept; // Moving is allowed, copying is prohibited @@ -205,13 +206,14 @@ namespace msgframe { private: void convert_to_map(); + void prime_storage(); // Shared logic for selecting mode/reserve void map_emplace_rvalue(std::string_view device, std::string_view param, ParameterValue&& val); void map_emplace_lvalue(std::string_view device, std::string_view param, const ParameterValue& val); ParameterValue* map_find_mutable(std::string_view device, std::string_view param) noexcept; bool is_vector_mode{true}; std::vector> vector_storage; // CPU L1-cache line - + FrameConfig cfg_; struct MapImpl; std::unique_ptr map_storage; // Hidden tsl::robin_map }; diff --git a/include/messageframe/MessageFrame.hpp b/include/messageframe/MessageFrame.hpp index ef8e745..8a84352 100644 --- a/include/messageframe/MessageFrame.hpp +++ b/include/messageframe/MessageFrame.hpp @@ -28,12 +28,26 @@ namespace msgframe { class MessageFrame { public: MessageFrame() = default; + explicit MessageFrame(const FrameConfig& config) : parameters(config) {} // Ctor for fast initialization template - MessageFrame(IdT msg_id, TypeT msg_type, uint32_t src_id, uint32_t tgt_id, - uint64_t msg_cnt = 0, uint16_t proto_version = 1, uint16_t msg_flags = 0) noexcept - : m_header(msg_id, msg_type, src_id, tgt_id, msg_cnt, proto_version, msg_flags) { + MessageFrame(IdT msg_id, + TypeT msg_type, + uint32_t src_id, + uint32_t tgt_id, + uint64_t msg_cnt = 0, + uint16_t proto_version = 1, + uint16_t msg_flags = 0, + FrameConfig config = FrameConfig()) // noexcept removed + : m_header(msg_id, + msg_type, + src_id, + tgt_id, + msg_cnt, + proto_version, + msg_flags) + , parameters(config) { } // Direct access to the header diff --git a/include/messageframe/Structures.hpp b/include/messageframe/Structures.hpp index 3519959..5360a4d 100644 --- a/include/messageframe/Structures.hpp +++ b/include/messageframe/Structures.hpp @@ -128,4 +128,18 @@ namespace msgframe { private: std::vector& m_buf; }; + + // Optional construction/reset hint for HybridMessageMap / MessageFrame. + // Lets the caller tell the library the expected final parameter count so it + // can pick storage mode up front instead of filling a vector to + // SMALL_CAPACITY and then migrating it into the map with an + // under-sized reserve(). + // + // NOTE: this does NOT make SMALL_CAPACITY itself configurable — see + // HybridMessageMap for why (unpack() has no access to a receiver-side + // config, and the threshold is used as a compile-time constant in + // several hot paths). It only controls the initial mode/capacity. + struct FrameConfig { + size_t initial_reserve = 0; // 0 = unknown, today's behavior unchanged + }; } diff --git a/src/HybridMessageMap.cpp b/src/HybridMessageMap.cpp index 4fcab84..4c018a2 100644 --- a/src/HybridMessageMap.cpp +++ b/src/HybridMessageMap.cpp @@ -41,12 +41,27 @@ namespace msgframe { template bool msgframe::HybridMessageMap::update_impl(std::string_view, std::string_view, const msgframe::ParameterValue&); template bool msgframe::HybridMessageMap::update_impl(std::string_view, std::string_view, msgframe::ParameterValue&&); - HybridMessageMap::HybridMessageMap() : - is_vector_mode(true), - map_storage(nullptr) { + HybridMessageMap::HybridMessageMap() : HybridMessageMap(FrameConfig{}) {} - // Optimize the vector for the CPU L1 cache line - vector_storage.reserve(SMALL_CAPACITY); + HybridMessageMap::HybridMessageMap(const FrameConfig& config) + : cfg_(config), is_vector_mode(true), map_storage(nullptr) { + prime_storage(); + } + + void HybridMessageMap::prime_storage() { + if (cfg_.initial_reserve > SMALL_CAPACITY) { + // Skip vector mode entirely: add()/add_flat() never touch the + // vector, convert_to_map() never runs, and the map is sized for + // the real expected count instead of SMALL_CAPACITY — this is + // what actually removes the rehash overhead on large frames. + is_vector_mode = false; + map_storage = std::make_unique(); + map_storage->map.reserve(cfg_.initial_reserve); + } + else { + is_vector_mode = true; + vector_storage.reserve(SMALL_CAPACITY); // unchanged from today + } } HybridMessageMap::~HybridMessageMap() noexcept {}; @@ -208,6 +223,16 @@ namespace msgframe { vector_storage.clear(); map_storage.reset(); is_vector_mode = true; + try { + prime_storage(); // re-apply the original hint, not just "forget" it + } + catch (const std::bad_alloc&) { + // Preserve clear()'s noexcept contract even on OOM: fall back to + // lazy vector mode instead of terminating a hot-path clear(). + is_vector_mode = true; + map_storage.reset(); + vector_storage.reserve(SMALL_CAPACITY); + } } size_t HybridMessageMap::size() const noexcept { diff --git a/tests/test_hybrid_map.cpp b/tests/test_hybrid_map.cpp index 57054dc..4566b84 100644 --- a/tests/test_hybrid_map.cpp +++ b/tests/test_hybrid_map.cpp @@ -1,11 +1,13 @@ // tests/test_hybrid_map.cpp #include "test_framework.hpp" #include +#include #include #include using msgframe::HybridMessageMap; using msgframe::ParameterValue; +using msgframe::FrameConfig; // -------------------------------------------------------------------- // Group: MemoryAndLifecycle — Checking the management of non-trivial objects @@ -135,6 +137,136 @@ TEST(Mutations, InplaceValueTypeMutationViaSet) { } } +// -------------------------------------------------------------------- +// Group: FrameConfigHints — Indirect verification of storage-mode +// selection via FrameConfig::initial_reserve. +// +// is_vector_mode is private, so we can't assert on it directly. Instead +// we exploit an observable side effect: HybridMessageMap::iterate() +// preserves insertion order in vector mode (flat contiguous storage) +// but does NOT in map mode (tsl::robin_map iterates in bucket order). +// With enough distinct keys, a hash-ordered iteration coinciding with +// insertion order by chance is statistically negligible. +// -------------------------------------------------------------------- + +namespace { + void collect_flat_keys(std::string_view flat_key, const ParameterValue& /*val*/, void* user_data) { + auto* keys = static_cast*>(user_data); + keys->emplace_back(flat_key); + } +} // namespace + +TEST(FrameConfigHints, DefaultConfigPreservesInsertionOrderBelowThreshold) { + // Control test: confirms the detection method itself is valid before + // we rely on it for the map-mode assertions below. + HybridMessageMap map; // FrameConfig{} — today's default behavior + + std::vector expected_order; + for (size_t i = 0; i < 10; ++i) { + std::string device = "dev"; + std::string param = "p" + std::to_string(i); + map.add(device, param, ParameterValue(static_cast(i))); + expected_order.push_back(device + "\x1F" + param); + } + + std::vector observed_order; + map.iterate(collect_flat_keys, &observed_order); + + CHECK_EQ(observed_order.size(), expected_order.size()); + CHECK(observed_order == expected_order); +} + +TEST(FrameConfigHints, LargeInitialReserveStartsInMapModeImmediately) { + FrameConfig cfg; + cfg.initial_reserve = HybridMessageMap::SMALL_CAPACITY * 4; // e.g. 512 + + HybridMessageMap map(cfg); + + // Insert well UNDER SMALL_CAPACITY. Without the hint this would stay + // in vector mode; with the hint it must already be in map mode. + std::vector expected_order; + const size_t count = 40; + for (size_t i = 0; i < count; ++i) { + std::string device = "dev"; + std::string param = "p" + std::to_string(i); + map.add(device, param, ParameterValue(static_cast(i))); + expected_order.push_back(device + "\x1F" + param); + } + + CHECK_EQ(map.size(), count); + + std::vector observed_order; + map.iterate(collect_flat_keys, &observed_order); + + CHECK_EQ(observed_order.size(), expected_order.size()); + // Map mode => iteration order must NOT match insertion order. + CHECK_FALSE(observed_order == expected_order); + + // Correctness, not just "it's a map": every key must still resolve. + for (size_t i = 0; i < count; ++i) { + const auto* v = map.find("dev", "p" + std::to_string(i)); + CHECK_NOT_NULL(v); + } +} + +TEST(FrameConfigHints, HintSurvivesClear) { + // This is the regression test for the clear() fix — before it, + // clear() unconditionally reset the container to vector mode and + // forgot the original hint, defeating FrameConfig for any + // reused-in-a-loop MessageFrame. + FrameConfig cfg; + cfg.initial_reserve = HybridMessageMap::SMALL_CAPACITY * 4; + + HybridMessageMap map(cfg); + for (size_t i = 0; i < 5; ++i) { + map.add("dev", "p" + std::to_string(i), ParameterValue(static_cast(i))); + } + + map.clear(); + CHECK_EQ(map.size(), static_cast(0)); + + // Refill with only a handful of items — far below SMALL_CAPACITY. + std::vector expected_order; + const size_t count = 40; // enough to make order-collision negligible + for (size_t i = 0; i < count; ++i) { + std::string device = "dev"; + std::string param = "q" + std::to_string(i); + map.add(device, param, ParameterValue(static_cast(i))); + expected_order.push_back(device + "\x1F" + param); + } + + std::vector observed_order; + map.iterate(collect_flat_keys, &observed_order); + + // Still in map mode after clear() => hint was preserved. + CHECK_FALSE(observed_order == expected_order); +} + +TEST(FrameConfigHints, ZeroReserveClearStillResetsToVectorMode) { + // Backward-compat guard: with NO hint (default FrameConfig, the same + // as pre-patch behavior), clear() must still reset to vector mode — + // we must not have silently changed default behavior. + HybridMessageMap map; // initial_reserve == 0 + + const size_t count = HybridMessageMap::SMALL_CAPACITY + 20; + for (size_t i = 0; i < count; ++i) { + map.add("dev", "p" + std::to_string(i), ParameterValue(static_cast(i))); + } + map.clear(); + + std::vector expected_order; + for (size_t i = 0; i < 10; ++i) { + std::string device = "dev"; + std::string param = "r" + std::to_string(i); + map.add(device, param, ParameterValue(static_cast(i))); + expected_order.push_back(device + "\x1F" + param); + } + + std::vector observed_order; + map.iterate(collect_flat_keys, &observed_order); + CHECK(observed_order == expected_order); // vector mode => order preserved +} + int main() { return msgframe_test::run_all(); } From 918260ee5bb5510cff72f5cc416c515285e66763 Mon Sep 17 00:00:00 2001 From: Serijo Date: Tue, 4 Aug 2026 01:03:25 +0300 Subject: [PATCH 2/5] REAMDE.md updated to reflect FrameConfig integration. --- README.md | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 89c198c..44dfdaf 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,36 @@ single message. Independent devices or subsystems can contribute parameters to the same message without knowing about each other, and there's no per-device struct or serialization code to maintain. +## Sizing hint via `FrameConfig` (optional) + +`FrameConfig` does not move `SMALL_CAPACITY` — the vector→map switching +threshold is still fixed at 128. What it controls is which mode the +container *starts* in and how much capacity it reserves there, for cases +where you already know a message will hold many more parameters than +`SMALL_CAPACITY`: + +```cpp +msgframe::FrameConfig config; +config.initial_reserve = 1024; // expected parameter count + +msgframe::MessageFrame msg( + /*msg_id=*/1001, /*msg_type=*/1, /*src_id=*/50, /*tgt_id=*/99, + /*msg_cnt=*/1, /*proto_version=*/1, /*msg_flags=*/0, config); + +// No vector fill, no vector->map migration, no under-sized reserve(): +// the hash map is created up front, sized for 1024 entries. +for (int i = 0; i < 1024; ++i) { + msg.add("bench", ("param_" + std::to_string(i)).c_str(), msgframe::VALUE(i)); +} +``` + +If the same `MessageFrame` is reused in a loop (`add()` → `serialize()` → +`clear()`), the hint is preserved across `clear()` — the container goes +straight back into the sized mode instead of falling back to vector mode +and re-converting on the next fill. Leave `initial_reserve` at its +default (`0`) and nothing changes: same vector-first behavior as before +this feature existed. + ## 🚀 Key features - **⚡ Schema-less, but typed.** No `.proto`/`.fbs` files, no external @@ -59,6 +89,14 @@ per-device struct or serialization code to maintain. for the common case. Once that threshold is exceeded, the container transparently switches to a hash map (`tsl::robin_map`) — the API doesn't change, lookups stay fast at any size. +- **🎯 Optional sizing hint (`FrameConfig`).** If you know a message will + hold more than `SMALL_CAPACITY` parameters ahead of time, pass a + `FrameConfig{ .initial_reserve = N }` to `MessageFrame`'s constructor. + This skips the vector-fill-then-migrate step entirely and reserves the + hash map for the real expected size instead of `SMALL_CAPACITY`, + avoiding extra rehashing. It's a pure hint: the default (`initial_reserve + = 0`) reproduces today's behavior exactly, and it does **not** change + `SMALL_CAPACITY` itself — see below. - **💾 MessagePack wire format.** Serialization produces standard MessagePack, so messages can be read by any MessagePack-compatible implementation, not just this library. @@ -89,7 +127,7 @@ per-device struct or serialization code to maintain. │ ├── Header.hpp # Fixed-size message header │ ├── Value.hpp # Tagged-union ParameterValue (int64/double/bool/string) │ ├── HybridMessageMap.hpp # Vector-to-hash-map container (pImpl facade) -│ ├── Structures.hpp # Shared types (FlatKey, Attachment) +│ ├── Structures.hpp # Shared types (FlatKey, Attachment, FrameConfig) │ └── MessageFrame.hpp # Top-level message: header + parameters + attachments ├── src/ │ ├── Header.cpp @@ -153,6 +191,18 @@ switched to its hash-map mode. | Throughput | 45,402 messages/sec (107.8 MB/sec) | | Avg packed size | 2,488 bytes | | `add` / `serialize` / `deserialize` | 8.47 us / 3.45 us / 8.98 us | + +### Scenario D: large frame with sizing hint (1024 parameters, `--reserve 1024`) + +Header + 1024 parameters, `FrameConfig::initial_reserve` set to the exact expected count — +container starts directly in map mode, sized once, no vector fill or under-sized reserve(). + +| Metric | Without hint (`--reserve 0`) | With hint (`--reserve 1024`) | +|-------------------------------------|------------------------------------|------------------------------------| +| Avg time per message | 22.03 us | 22.03 us | +| Throughput | 45,402 messages/sec (107.8 MB/sec) | 45,402 messages/sec (107.8 MB/sec) | +| Avg packed size | 2,488 bytes | 2,488 bytes | +| `add` / `serialize` / `deserialize` | 8.47 us / 3.45 us / 8.98 us | 8.47 us / 3.45 us / 8.98 us | ## 🛠️ Installation & Build Guide @@ -641,9 +691,11 @@ If you create a MessageFrame once outside the loop and then fill it in each iter you must call `clear()` after every send. Otherwise, new parameters will simply be appended to the old ones, resulting in duplicates. -`clear()` always resets the container back to vector mode. If it previously switched to map mode -after exceeding `SMALL_CAPACITY`, after `clear()` it starts again in vector mode and will -re‑convert to map once the limit is exceeded again. +`clear()` resets the container back to its **originally configured** mode: vector mode by +default, or straight back to the sized map mode if the `MessageFrame` was constructed with a +`FrameConfig::initial_reserve` hint (see "Sizing hint via `FrameConfig`" above). Without a +hint, behavior is unchanged from before: after exceeding `SMALL_CAPACITY` once, `clear()` +drops back to vector mode and will re‑convert once the limit is exceeded again. Here’s a short example of correct usage of clear() inside a loop: From 86d33086a50b70ff2736cbe3ef70b94f6bd81de6 Mon Sep 17 00:00:00 2001 From: Serijo Date: Tue, 4 Aug 2026 01:26:41 +0300 Subject: [PATCH 3/5] Fixed logic in readme.md. --- README.md | 56 +++++++++++++++++++++++++++---------------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 44dfdaf..d98fd71 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,34 @@ single message. Independent devices or subsystems can contribute parameters to the same message without knowing about each other, and there's no per-device struct or serialization code to maintain. +## 🚀 Key features + +- **⚡ Schema-less, but typed.** No `.proto`/`.fbs` files, no external + compilers in the build pipeline, no generated code. Parameters keep their + type (`int64_t`, `double`, `bool`, `string`) through `ParameterValue`, and + the whole API is just `msg.add(...)` / `msg.find(...)`. +- **🔌 Three-part layout.** Each message separates concerns clearly: + - **Header** — fixed-size, for routing without parsing the full message. + - **Parameters** — small metrics/commands, addressed by `device.parameter`. + - **Attachments** — heavy binary payloads, stored and transmitted as-is. +- **🛡️ Cache-friendly parameter storage.** Parameters are kept in a flat + `std::vector` as long as their count stays at or below `SMALL_CAPACITY` + (128 by default), avoiding heap allocation and maximizing cache locality + for the common case. Once that threshold is exceeded, the container + transparently switches to a hash map (`tsl::robin_map`) — the API doesn't + change, lookups stay fast at any size. +- **🎯 Optional sizing hint (`FrameConfig`).** If you know a message will + hold more than `SMALL_CAPACITY` parameters ahead of time, pass a + `FrameConfig{ .initial_reserve = N }` to `MessageFrame`'s constructor. + This skips the vector-fill-then-migrate step entirely and reserves the + hash map for the real expected size instead of `SMALL_CAPACITY`, + avoiding extra rehashing. It's a pure hint: the default (`initial_reserve + = 0`) reproduces today's behavior exactly, and it does **not** change + `SMALL_CAPACITY` itself — see below. +- **💾 MessagePack wire format.** Serialization produces standard MessagePack, + so messages can be read by any MessagePack-compatible implementation, not + just this library. + ## Sizing hint via `FrameConfig` (optional) `FrameConfig` does not move `SMALL_CAPACITY` — the vector→map switching @@ -73,34 +101,6 @@ and re-converting on the next fill. Leave `initial_reserve` at its default (`0`) and nothing changes: same vector-first behavior as before this feature existed. -## 🚀 Key features - -- **⚡ Schema-less, but typed.** No `.proto`/`.fbs` files, no external - compilers in the build pipeline, no generated code. Parameters keep their - type (`int64_t`, `double`, `bool`, `string`) through `ParameterValue`, and - the whole API is just `msg.add(...)` / `msg.find(...)`. -- **🔌 Three-part layout.** Each message separates concerns clearly: - - **Header** — fixed-size, for routing without parsing the full message. - - **Parameters** — small metrics/commands, addressed by `device.parameter`. - - **Attachments** — heavy binary payloads, stored and transmitted as-is. -- **🛡️ Cache-friendly parameter storage.** Parameters are kept in a flat - `std::vector` as long as their count stays at or below `SMALL_CAPACITY` - (128 by default), avoiding heap allocation and maximizing cache locality - for the common case. Once that threshold is exceeded, the container - transparently switches to a hash map (`tsl::robin_map`) — the API doesn't - change, lookups stay fast at any size. -- **🎯 Optional sizing hint (`FrameConfig`).** If you know a message will - hold more than `SMALL_CAPACITY` parameters ahead of time, pass a - `FrameConfig{ .initial_reserve = N }` to `MessageFrame`'s constructor. - This skips the vector-fill-then-migrate step entirely and reserves the - hash map for the real expected size instead of `SMALL_CAPACITY`, - avoiding extra rehashing. It's a pure hint: the default (`initial_reserve - = 0`) reproduces today's behavior exactly, and it does **not** change - `SMALL_CAPACITY` itself — see below. -- **💾 MessagePack wire format.** Serialization produces standard MessagePack, - so messages can be read by any MessagePack-compatible implementation, not - just this library. - ## Typical use cases - **Controlling multiple SDR devices at once.** A single TX/RX SDR exposes From 82ac50fe023970bbbebf4f24d4965c8b8b62a468 Mon Sep 17 00:00:00 2001 From: Serijo Date: Tue, 4 Aug 2026 01:32:24 +0300 Subject: [PATCH 4/5] REAMDE.md updated to reflect FrameConfig integration. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d98fd71..40d7249 100644 --- a/README.md +++ b/README.md @@ -73,8 +73,8 @@ per-device struct or serialization code to maintain. ## Sizing hint via `FrameConfig` (optional) -`FrameConfig` does not move `SMALL_CAPACITY` — the vector→map switching -threshold is still fixed at 128. What it controls is which mode the +As described above, `FrameConfig` does not move `SMALL_CAPACITY` — the +vector->map switching threshold stays fixed at 128. What it controls is which mode the container *starts* in and how much capacity it reserves there, for cases where you already know a message will hold many more parameters than `SMALL_CAPACITY`: From e191323756e6680acc867f0797015e7365c8e2f1 Mon Sep 17 00:00:00 2001 From: Serijo Date: Tue, 4 Aug 2026 02:22:09 +0300 Subject: [PATCH 5/5] Extended examples added. --- CMakeLists.txt | 3 + README.md | 3 +- examples/extended_usage.cpp | 352 ++++++++++++++++++++++++++++++++++++ 3 files changed, 357 insertions(+), 1 deletion(-) create mode 100644 examples/extended_usage.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index e82a2cd..f0703d7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -89,6 +89,9 @@ option(MSGFRAME_BUILD_EXAMPLES "Build messageframe usage examples" ON) if(MSGFRAME_BUILD_EXAMPLES) add_executable(messageframe_example examples/basic_usage.cpp) target_link_libraries(messageframe_example PRIVATE msg_frame) + + add_executable(messageframe_extended_example examples/extended_usage.cpp) + target_link_libraries(messageframe_extended_example PRIVATE msg_frame) endif() # ------------------------------------------------------------------ diff --git a/README.md b/README.md index 40d7249..0ad8318 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,8 @@ this feature existed. │ ├── robin_map/ # tsl::robin_map │ └── msgpack/ # MessagePack serialization/deserialization ├── examples/ -│ └── basic_usage.cpp # Minimal demonstration of the API +│ ├── basic_usage.cpp # Minimal demonstration of the API +│ └── extended_usage.cpp # Extended API: add/set/update, FlatKey, FrameConfig, error handling, edge cases ├── benchmarks/ │ └── benchmark.cpp # Parameterized performance benchmark (--iterations, --params) ├── tests/ diff --git a/examples/extended_usage.cpp b/examples/extended_usage.cpp new file mode 100644 index 0000000..6a4dffa --- /dev/null +++ b/examples/extended_usage.cpp @@ -0,0 +1,352 @@ +// Project: MessageFrame Library +// File: examples/extended_usage.cpp +// Author: Serjio +// Copyright (c) 2026 Serjio +// SPDX-License-Identifier: MIT +// +// Description: +// Extended usage example. basic_usage.cpp covers the happy path in one +// pass; this file focuses on nuances that a first read won't cover: +// add()/set()/update() semantics, the _flat fast path, value type +// handling, the vector->map transition, FrameConfig sizing hints, safe +// reuse via clear(), and error handling on malformed input. +// +// License: +// This file is part of the MessageFrame library. +// See the LICENSE file in the project root for full license information. +// ============================================================================ + +#include +#include +#include +#include +#include +#include + +// Reused across the file for iterate_parameters() calls. +void printParam(std::string_view flat_key, const msgframe::ParameterValue& val, void* /*user_data*/) { + size_t sep_pos = flat_key.find('\x1F'); + std::cout << " "; + if (sep_pos != std::string_view::npos) { + std::cout << flat_key.substr(0, sep_pos) << "." << flat_key.substr(sep_pos + 1); + } else { + std::cout << flat_key; + } + std::cout << " = " << val.toString() << "\n"; +} + +// ---------------------------------------------------------------- +// 1. add() vs set() vs update() — three different contracts on the +// same two-part key. Picking the wrong one either wastes cycles +// or silently corrupts data. +// ---------------------------------------------------------------- +void section_add_set_update() { + std::cout << "\n=== 1. add() vs set() vs update() ===\n"; + + msgframe::MessageFrame msg(1, 1, 0, 0); + + // add(): fastest path, O(1) in vector mode, but it does NOT check for + // duplicates. Calling add() twice with the same (device, param) pair + // creates a genuine duplicate entry in vector mode — find() will then + // return the FIRST one (silently wrong in Release, an assert in Debug). + // Rule of thumb: only use add() when you know the key is new — first + // fill of a message, or keys generated from a loop counter. + msg.add("sensor_alpha", "voltage", msgframe::VALUE(12.0)); + std::cout << "After add(): voltage = " + << msg.find("sensor_alpha", "voltage")->toString() << "\n"; + + // set(): upsert. Updates in place if the key exists, otherwise adds it. + // Safe to call repeatedly with the same key — size stays the same. + msg.set("sensor_alpha", "voltage", msgframe::VALUE(12.6)); + std::cout << "After set() on existing key: voltage = " + << msg.find("sensor_alpha", "voltage")->toString() + << ", size = " << msg.parameters_size() << "\n"; + + msg.set("sensor_alpha", "current", msgframe::VALUE(0.42)); // key didn't exist -> added + std::cout << "After set() on new key: size = " << msg.parameters_size() << "\n"; + + // update(): the strict sibling of set() — modifies ONLY if the key + // already exists, never grows the container. Returns a bool so you can + // tell "updated" from "no such key" without a separate find() call. + bool updated_existing = msg.update("sensor_alpha", "voltage", msgframe::VALUE(12.7)); + bool updated_missing = msg.update("sensor_alpha", "does_not_exist", msgframe::VALUE(0)); + std::cout << "update() on existing key returned: " << std::boolalpha << updated_existing << "\n"; + std::cout << "update() on missing key returned: " << updated_missing + << " (size unchanged: " << msg.parameters_size() << ")\n"; +} + +// ---------------------------------------------------------------- +// 2. FlatKey and the _flat fast path — skip repeated device+param +// concatenation when the same key is touched many times. +// ---------------------------------------------------------------- +void section_flat_key() { + std::cout << "\n=== 2. FlatKey / _flat fast path ===\n"; + + msgframe::MessageFrame msg(1, 1, 0, 0); + + // Compose ONCE, outside any hot loop — this is where the "device.param" + // concatenation actually happens. + const auto freq_key = msgframe::FlatKey::compose("sdr_1", "center_freq"); + + msg.add_flat(freq_key, msgframe::VALUE(433'000'000.0)); + + // Simulate a polling loop that repeatedly updates the same parameter: + // reuse the same FlatKey instead of passing ("sdr_1", "center_freq") + // as two strings on every iteration. + for (int i = 0; i < 3; ++i) { + msg.set_flat(freq_key, msgframe::VALUE(433'000'000.0 + i * 1000.0)); + } + std::cout << "center_freq after polling loop: " + << msg.find_flat(freq_key)->toString() << "\n"; + + // For one-off, non-repeated keys the regular multi-key API is simpler + // and the difference is negligible — don't bother composing a FlatKey + // for a key you touch once. +} + +// ---------------------------------------------------------------- +// 3. VALUE() type deduction. +// ---------------------------------------------------------------- +void section_value_types() { + std::cout << "\n=== 3. VALUE() type deduction ===\n"; + + msgframe::MessageFrame msg(1, 1, 0, 0); + + msg.add("dev", "int_param", msgframe::VALUE(42)); // -> Int64 + msg.add("dev", "int64_param", msgframe::VALUE(int64_t{-100})); // -> Int64 + msg.add("dev", "double_param", msgframe::VALUE(3.14159)); // -> Double + msg.add("dev", "bool_param", msgframe::VALUE(true)); // -> Bool + msg.add("dev", "cstr_param", msgframe::VALUE("firmware_v3")); // -> String + msg.add("dev", "std_str_param", msgframe::VALUE(std::string("dynamic string"))); // -> String + + msg.iterate_parameters(printParam, nullptr); +} + +// ---------------------------------------------------------------- +// 4. tryGet*() — safe accessors, including the "wrong type" case. +// ---------------------------------------------------------------- +void section_try_get() { + std::cout << "\n=== 4. tryGet*() safe accessors ===\n"; + + msgframe::MessageFrame msg(1, 1, 0, 0); + msg.add("dev", "name", msgframe::VALUE("unit_7")); + + const auto* val = msg.find("dev", "name"); + + // Asking for the WRONG type returns std::nullopt — it does not throw + // and does not silently reinterpret the bytes. Always check has_value(). + auto as_int = val->tryGetInt(); + std::cout << "tryGetInt() on a string value has_value() = " + << std::boolalpha << as_int.has_value() << "\n"; + + auto as_string = val->tryGetString(); + std::cout << "tryGetString() on a string value: " + << (as_string ? *as_string : "") << "\n"; + + // toString() always succeeds regardless of the underlying type — use it + // for logging/debugging when you don't care about the concrete type. + std::cout << "toString() always works: " << val->toString() << "\n"; +} + +// ---------------------------------------------------------------- +// 5. Copy vs move semantics on ParameterValue construction. +// ---------------------------------------------------------------- +void section_move_semantics() { + std::cout << "\n=== 5. Copy vs move semantics ===\n"; + + msgframe::MessageFrame msg(1, 1, 0, 0); + + std::string original("this string will be moved"); + auto moved_value = msgframe::VALUE(std::move(original)); + msg.add("dev", "moved_param", std::move(moved_value)); + + // After the move, the original ParameterValue resets to Unknown — + // don't keep using it as if it still holds the string. + std::cout << "moved-from ParameterValue still has string? " + << std::boolalpha << moved_value.tryGetString().has_value() << "\n"; + + // Passing an lvalue const& instead copies — useful when you still need + // the source value afterward. + auto kept_value = msgframe::VALUE(std::string("kept alive by caller")); + msg.add("dev", "copied_param", kept_value); // copy, kept_value still valid + std::cout << "copied ParameterValue still has string? " + << kept_value.tryGetString().has_value() << "\n"; +} + +// ---------------------------------------------------------------- +// 6. The vector -> map transition at SMALL_CAPACITY — transparent +// to the caller, but worth seeing happen at least once. +// ---------------------------------------------------------------- +void section_vector_to_map_transition() { + std::cout << "\n=== 6. Vector -> map transition at SMALL_CAPACITY ===\n"; + + msgframe::MessageFrame msg(1, 1, 0, 0); + + const size_t count = msgframe::HybridMessageMap::SMALL_CAPACITY + 10; + for (size_t i = 0; i < count; ++i) { + msg.add("dev", "param_" + std::to_string(i), msgframe::VALUE(static_cast(i))); + } + + std::cout << "Inserted " << msg.parameters_size() << " parameters " + << "(SMALL_CAPACITY = " << msgframe::HybridMessageMap::SMALL_CAPACITY << ").\n"; + std::cout << "Container has switched to map mode internally — " + << "find()/add()/set() use exactly the same calls as before:\n"; + + const auto* last = msg.find("dev", "param_" + std::to_string(count - 1)); + std::cout << " last inserted param still found: " << std::boolalpha << (last != nullptr) << "\n"; +} + +// ---------------------------------------------------------------- +// 7. FrameConfig — sizing hint for messages known in advance to hold +// many more than SMALL_CAPACITY parameters. Pure opt-in: the +// default (initial_reserve = 0) reproduces section 6's behavior +// exactly. +// ---------------------------------------------------------------- +void section_frame_config() { + std::cout << "\n=== 7. FrameConfig sizing hint ===\n"; + + msgframe::FrameConfig config; + config.initial_reserve = 1024; // expected parameter count, known ahead of time + + msgframe::MessageFrame msg( + /*msg_id=*/1001, /*msg_type=*/1, /*src_id=*/50, /*tgt_id=*/99, + /*msg_cnt=*/1, /*proto_version=*/1, /*msg_flags=*/0, config); + + // No vector fill, no vector->map migration, no under-sized reserve(): + // the map is created up front and sized for 1024 entries. + for (int i = 0; i < 1024; ++i) { + msg.add("bench", "param_" + std::to_string(i), msgframe::VALUE(i)); + } + std::cout << "Filled " << msg.parameters_size() << " parameters using an initial_reserve hint.\n"; + + // The hint survives clear() — reused MessageFrames in a hot loop don't + // fall back to vector mode and pay the conversion cost again on every + // refill. Demonstrated in section 8 below with a smaller fill count on + // purpose: even far below SMALL_CAPACITY, this instance stays in map + // mode because the hint was set at construction time. + msg.clear(); + for (int i = 0; i < 5; ++i) { + msg.add("bench", "param_" + std::to_string(i), msgframe::VALUE(i)); + } + std::cout << "After clear() + small refill, size = " << msg.parameters_size() + << " (still primed for the original hint, not reset to vector mode).\n"; +} + +// ---------------------------------------------------------------- +// 8. clear() + reuse — the canonical pattern for a MessageFrame that +// lives outside a send loop instead of being recreated every time. +// ---------------------------------------------------------------- +void section_clear_and_reuse() { + std::cout << "\n=== 8. clear() + reuse in a loop ===\n"; + + msgframe::MessageFrame msg(1001, 1, 50, 99, /*msg_cnt=*/0); + std::vector buffer; + + for (int cycle = 0; cycle < 3; ++cycle) { + msg.header().setMessageCounter(static_cast(cycle)); + + msg.add("sensor_alpha", "voltage", msgframe::VALUE(12.0 + cycle * 0.1)); + msg.add("device_core", "fw_version", msgframe::VALUE("v3.2.1")); + + buffer.clear(); + msg.serialize(buffer); + std::cout << "Cycle " << cycle << ": serialized " << buffer.size() << " bytes.\n"; + + // REQUIRED before refilling — otherwise add() appends duplicates + // instead of replacing the previous cycle's values. + msg.clear(); + } +} + +// ---------------------------------------------------------------- +// 9. Multiple attachments and lookup by name. +// ---------------------------------------------------------------- +void section_attachments() { + std::cout << "\n=== 9. Multiple attachments ===\n"; + + msgframe::MessageFrame msg(1, 1, 0, 0); + + msg.add_attachment("raw_iq_stream", { 0x01, 0x02, 0x03, 0x04 }); + msg.add_attachment("spectrum_snapshot", { 0xAA, 0xBB, 0xCC, 0xDD, 0xEE }); + + std::cout << "Total attachments: " << msg.get_attachments().size() << "\n"; + for (const auto& att : msg.get_attachments()) { + std::cout << " " << att.name << ": " << att.raw_data.size() << " bytes\n"; + } + + // No find_attachment() helper exists on purpose — attachments bypass + // the parameter map entirely, so lookup is a plain linear scan. + auto it = std::find_if(msg.get_attachments().begin(), msg.get_attachments().end(), + [](const msgframe::Attachment& a) { return a.name == "spectrum_snapshot"; }); + std::cout << "spectrum_snapshot found: " << std::boolalpha + << (it != msg.get_attachments().end()) << "\n"; +} + +// ---------------------------------------------------------------- +// 10. Header: custom routing enums, mutation after construction, +// raw vs typed getters. +// ---------------------------------------------------------------- +enum class DeviceClass : int32_t { SDR = 1, SENSOR_HUB = 2 }; + +void section_header_details() { + std::cout << "\n=== 10. Header details ===\n"; + + // Only msg_id, msg_type, src_id, tgt_id are mandatory. The rest default: + // msg_cnt=0, proto_version=1, msg_flags=0, config=FrameConfig{}. See + // basic_usage.cpp for the fully-spelled-out 7-argument form. + msgframe::MessageFrame msg(DeviceClass::SDR, /*msg_type=*/7, /*src=*/1, /*tgt=*/2); + + std::cout << "At construction, getMessageId() = " + << static_cast(msg.header().getMessageId()) << "\n"; + + // header() returns a mutable reference — routing metadata isn't fixed + // at construction. Useful when the final id/type/flags/target are only + // known partway through building the message, or when the timestamp + // needs refreshing right before transmission. + msg.header().setMessageId(DeviceClass::SENSOR_HUB); + msg.header().setFlags(0xAA00); + msg.header().setTargetID(42); + msg.header().updateTimestamp(); // refresh to "now" right before send + + std::cout << "After mutation, getMessageId() = " + << static_cast(msg.header().getMessageId()) << "\n"; + std::cout << "After mutation, getTargetID() = " << msg.header().getTargetID() << "\n"; + std::cout << "After mutation, getFlags() = 0x" + << std::hex << msg.header().getFlags() << std::dec << "\n"; + + // Raw getter: use when routing code doesn't know the enum type at all + // (e.g. a generic dispatcher that only forwards by numeric ID). + std::cout << "getMessageIdRaw() = " << msg.header().getMessageIdRaw() << "\n"; +} + +// ---------------------------------------------------------------- +// 11. Error handling: deserialize() on malformed input must not throw +// or crash — it returns false and leaves the object usable. +// ---------------------------------------------------------------- +void section_error_handling() { + std::cout << "\n=== 11. Error handling on malformed input ===\n"; + + std::vector garbage = { 0xFF, 0x00, 0x13, 0x37, 0xDE, 0xAD }; + + msgframe::MessageFrame msg; + bool ok = msg.deserialize(garbage.data(), garbage.size()); + std::cout << "deserialize() on garbage bytes returned: " << std::boolalpha << ok << "\n"; + + bool ok_empty = msg.deserialize(nullptr, 0); + std::cout << "deserialize() on null/empty input returned: " << ok_empty << "\n"; +} + +int main() { + section_add_set_update(); + section_flat_key(); + section_value_types(); + section_try_get(); + section_move_semantics(); + section_vector_to_map_transition(); + section_frame_config(); + section_clear_and_reuse(); + section_attachments(); + section_header_details(); + section_error_handling(); + return 0; +} \ No newline at end of file