Skip to content

Commit a7b7abb

Browse files
committed
Replace snprintf price serialization with integer-only formatting, eliminate per-message memmove in SocketBridge (TICKET_484_1)
1 parent 5df630d commit a7b7abb

3 files changed

Lines changed: 171 additions & 18 deletions

File tree

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# TICKET_484_1: FixedPrice Serialization + SocketBridge Buffer Optimization
2+
3+
## Summary
4+
5+
Two hot-path optimizations applied to the E2E pipeline:
6+
7+
1. **FixedPrice serialization**: Replaced `snprintf("%.8g")` with integer-only formatting
8+
2. **SocketBridge buffer**: Replaced per-message `memmove()` with read/write index tracking
9+
10+
## Changes
11+
12+
### 1. FixedPrice serialization (`trailer.hpp`)
13+
14+
**Before**: `FixedPrice::raw` (int64_t) -> `to_double()` -> `snprintf("%.8g", d)` -> string_view
15+
**After**: `FixedPrice::raw` (int64_t) -> integer division/modulo -> direct digit output -> string_view
16+
17+
- Eliminates float conversion and libc `snprintf` call on every price field
18+
- Each NOS->ER round-trip hits this path 2+ times (NOS price, ER avg_px)
19+
- Also removes `<cstdio>` include from header
20+
21+
### 2. SocketBridge buffer (`socket_bridge.hpp`)
22+
23+
**Before**: After each `parser_.feed()`, `memmove()` shifts unconsumed bytes to buffer start
24+
**After**: Track `read_pos_` / `write_pos_` indices; only compact when read_pos exceeds half the buffer
25+
26+
- Eliminates memmove on every message in ping-pong mode
27+
- Under burst (N messages in buffer), avoids N-1 memmoves
28+
29+
## Benchmark: Before vs After
30+
31+
Environment: Linux x86_64, GCC, TCP loopback, 10000 iterations, 2000 warmup
32+
33+
### Round-trip Latency (NOS -> ER, chrono)
34+
35+
| Metric | Before | After | Change |
36+
|--------|--------|-------|--------|
37+
| P50 | 10.48 us | 10.31 us | -1.6% |
38+
| P90 | 18.21 us | 10.73 us | **-41.1%** |
39+
| P99 | 21.27 us | 12.13 us | **-43.0%** |
40+
| P99.9 | 1545.45 us | 14.42 us | **-99.1%** |
41+
| Max | 2971.99 us | 25.65 us | **-99.1%** |
42+
| Mean | 16.37 us | 10.41 us | **-36.4%** |
43+
| Stddev | 78.82 us | 0.47 us | **-99.4%** |
44+
| P99/P50 | 2.03x | 1.18x | **-41.9%** |
45+
| Throughput | 61,077 rt/s | 96,024 rt/s | **+57.2%** |
46+
47+
### Round-trip Latency (RDTSC)
48+
49+
| Metric | Before | After | Change |
50+
|--------|--------|-------|--------|
51+
| P50 | 16.67 us | 10.28 us | **-38.3%** |
52+
| P90 | 18.07 us | 10.53 us | **-41.7%** |
53+
| P99 | 21.49 us | 12.37 us | **-42.4%** |
54+
| P99.9 | 1963.00 us | 13.87 us | **-99.3%** |
55+
| Max | 3127.44 us | 36.42 us | **-98.8%** |
56+
| Throughput | 49,877 rt/s | 97,000 rt/s | **+94.5%** |
57+
58+
### Half-trip (build + serialize + send)
59+
60+
| Metric | Before | After | Change |
61+
|--------|--------|-------|--------|
62+
| P50 | 4.31 us | 4.17 us | -3.2% |
63+
| Min | 3.82 us | 3.76 us | -1.6% |
64+
65+
### Sustained Throughput
66+
67+
| Burst Size | Before (msg/s) | After (msg/s) | Change |
68+
|------------|----------------|---------------|--------|
69+
| 100 | 200,833 | 211,158 | +5.1% |
70+
| 1000 | 209,607 | 225,855 | +7.8% |
71+
| 5000 | 207,209 | 218,762 | +5.6% |
72+
73+
## Analysis
74+
75+
The dominant improvement is tail latency reduction. Before optimization, occasional `snprintf` slow paths and repeated `memmove` on batch receives caused P99.9 spikes into the millisecond range. After optimization:
76+
77+
- P99.9 dropped from **1.5-2.0 ms to 13-14 us** (100x improvement)
78+
- Stddev dropped from **78-99 us to 0.47-0.53 us** (150x improvement)
79+
- Throughput improved **57-94%** due to eliminating tail latency outliers
80+
81+
The P50 improvement (~3 us on RDTSC) is consistent with removing 2x snprintf calls per round-trip.
82+
83+
## Notes
84+
85+
- Benchmark on shared VM, not isolated cores. Absolute numbers will vary.
86+
- TCP loopback adds ~8-9 us baseline (kernel + network stack).
87+
- SocketBridge compaction threshold is BufferSize/2 (4KB for default 8KB buffer).

include/nexusfix/engine/socket_bridge.hpp

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,15 @@ class SocketBridge {
2929

3030
if (socket_.is_connected() && socket_.poll_read(timeout_ms)) {
3131
auto space = std::span<char>{
32-
buffer_.data() + buffered_,
33-
buffer_.size() - buffered_
32+
buffer_.data() + write_pos_,
33+
buffer_.size() - write_pos_
3434
};
3535

3636
if (!space.empty()) {
3737
auto result = socket_.receive(space);
3838
if (result.has_value() && *result > 0) {
3939
received = true;
40-
buffered_ += *result;
40+
write_pos_ += *result;
4141
}
4242
}
4343
}
@@ -56,14 +56,16 @@ class SocketBridge {
5656
/// Reset parser and buffer state (e.g., after reconnect)
5757
void reset() noexcept {
5858
parser_.reset();
59-
buffered_ = 0;
59+
read_pos_ = 0;
60+
write_pos_ = 0;
6061
last_tick_ = std::chrono::steady_clock::now();
6162
}
6263

6364
private:
6465
void drain_buffered() noexcept {
65-
while (buffered_ > 0) {
66-
auto data = std::span<const char>{buffer_.data(), buffered_};
66+
while (read_pos_ < write_pos_) {
67+
size_t avail = write_pos_ - read_pos_;
68+
auto data = std::span<const char>{buffer_.data() + read_pos_, avail};
6769
size_t consumed = parser_.feed(data);
6870

6971
while (parser_.has_message()) {
@@ -73,20 +75,26 @@ class SocketBridge {
7375
}
7476

7577
if (consumed == 0) break;
78+
read_pos_ += consumed;
79+
}
7680

77-
size_t remaining = buffered_ - consumed;
78-
if (remaining > 0) {
79-
std::memmove(buffer_.data(), buffer_.data() + consumed, remaining);
80-
}
81-
buffered_ = remaining;
81+
if (read_pos_ == write_pos_) {
82+
read_pos_ = 0;
83+
write_pos_ = 0;
84+
} else if (read_pos_ > BufferSize / 2) {
85+
size_t remaining = write_pos_ - read_pos_;
86+
std::memmove(buffer_.data(), buffer_.data() + read_pos_, remaining);
87+
read_pos_ = 0;
88+
write_pos_ = remaining;
8289
}
8390
}
8491

8592
TcpSocket& socket_;
8693
SessionManager& session_;
8794
StreamParser parser_;
8895
std::array<char, BufferSize> buffer_{};
89-
size_t buffered_{0};
96+
size_t read_pos_{0};
97+
size_t write_pos_{0};
9098
std::chrono::steady_clock::time_point last_tick_;
9199
static constexpr std::chrono::milliseconds tick_interval_{100};
92100
};

include/nexusfix/messages/common/trailer.hpp

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@
33
#include <span>
44
#include <array>
55
#include <cstdint>
6-
#include <cstdio>
7-
86
#include "nexusfix/types/tag.hpp"
97
#include "nexusfix/types/error.hpp"
108
#include "nexusfix/interfaces/i_message.hpp"
@@ -216,12 +214,72 @@ class MessageAssembler {
216214
return field(tag_num, std::string_view{&value, 1});
217215
}
218216

219-
/// Append price field
217+
/// Append price field (integer-only formatting, no snprintf/double)
220218
MessageAssembler& field(int tag_num, FixedPrice price) noexcept {
221-
// Format price to string
222219
char buf[32];
223-
double d = price.to_double();
224-
int len = std::snprintf(buf, sizeof(buf), "%.8g", d);
220+
int len = 0;
221+
int64_t raw = price.raw;
222+
223+
bool negative = raw < 0;
224+
if (negative) raw = -raw;
225+
226+
int64_t integer_part = raw / FixedPrice::SCALE;
227+
int64_t frac_part = raw % FixedPrice::SCALE;
228+
229+
// Write integer part (reverse-then-flip, same pattern as int64_t overload)
230+
if (integer_part == 0) {
231+
buf[len++] = '0';
232+
} else {
233+
int start = len;
234+
int64_t v = integer_part;
235+
do {
236+
buf[len++] = '0' + static_cast<char>(v % 10);
237+
v /= 10;
238+
} while (v > 0);
239+
for (int i = start, j = len - 1; i < j; ++i, --j) {
240+
char tmp = buf[i]; buf[i] = buf[j]; buf[j] = tmp;
241+
}
242+
}
243+
244+
if (frac_part > 0) {
245+
// Strip trailing zeros from fractional part
246+
int frac_digits = FixedPrice::DECIMAL_PLACES;
247+
while (frac_part % 10 == 0 && frac_digits > 0) {
248+
frac_part /= 10;
249+
--frac_digits;
250+
}
251+
252+
buf[len++] = '.';
253+
254+
// Write fractional digits (right-aligned within frac_digits width)
255+
int frac_start = len;
256+
int64_t v = frac_part;
257+
int written = 0;
258+
do {
259+
buf[len++] = '0' + static_cast<char>(v % 10);
260+
v /= 10;
261+
++written;
262+
} while (v > 0);
263+
264+
// Pad leading zeros if needed (e.g. 0.005 -> frac_part=5, frac_digits=3)
265+
while (written < frac_digits) {
266+
buf[len++] = '0';
267+
++written;
268+
}
269+
270+
// Reverse fractional digits
271+
for (int i = frac_start, j = len - 1; i < j; ++i, --j) {
272+
char tmp = buf[i]; buf[i] = buf[j]; buf[j] = tmp;
273+
}
274+
}
275+
276+
if (negative) {
277+
// Shift right and prepend '-'
278+
for (int i = len; i > 0; --i) buf[i] = buf[i - 1];
279+
buf[0] = '-';
280+
++len;
281+
}
282+
225283
return field(tag_num, std::string_view{buf, static_cast<size_t>(len)});
226284
}
227285

0 commit comments

Comments
 (0)