Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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()

# ------------------------------------------------------------------
Expand Down
63 changes: 58 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,48 @@ 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.

## Sizing hint via `FrameConfig` (optional)

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`:

```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.

## Typical use cases

- **Controlling multiple SDR devices at once.** A single TX/RX SDR exposes
Expand All @@ -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
Expand All @@ -100,7 +138,8 @@ per-device struct or serialization code to maintain.
│ ├── 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/
Expand Down Expand Up @@ -153,6 +192,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

Expand Down Expand Up @@ -641,9 +692,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:

Expand Down
27 changes: 19 additions & 8 deletions benchmarks/benchmark.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -58,6 +60,8 @@ BenchmarkConfig parseArgs(int argc, char** argv) {
cfg.iterations = static_cast<size_t>(std::strtoull(argv[++i], nullptr, 10));
} else if (std::strcmp(argv[i], "--params") == 0 && i + 1 < argc) {
cfg.params_count = static_cast<size_t>(std::strtoull(argv[++i], nullptr, 10));
} else if (std::strcmp(argv[i], "--reserve") == 0 && i + 1 < argc) {
cfg.reserve_hint = static_cast<size_t>(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);
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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";
Expand Down
Loading
Loading