refactor(dpi/quic): single-allocation connection_id_to_hex#308
refactor(dpi/quic): single-allocation connection_id_to_hex#3080xghost42 wants to merge 1 commit into
connection_id_to_hex#308Conversation
`connection_id_to_hex` was rendering a QUIC connection ID through the
worst-case allocation pattern: `format!("{:02x}", b)` allocated a
2-byte `String` for every input byte, the iterator collected them into
a `Vec<String>` (one more allocation), and `join(":")` then walked the
vector to build the final `String`. For an 8-byte short-form DCID that
is 8 + 1 + 1 = 10 heap allocations per render.
Replace with a single `String::with_capacity(id.len() * 3 - 1)` (exact
final size: 2 hex chars per byte + N-1 colon separators) and a per-byte
`write!`. Writing into a pre-sized `String` does not reallocate, so the
helper now performs exactly one heap allocation per call regardless of
input length, except for the empty-slice case which returns an empty
`String` (no allocation). The lowercase, colon-separated output is
unchanged.
Adds four regression tests covering:
- An 8-byte representative DCID — locks the lowercase + colon-separated
contract.
- A mix of single-digit bytes (0x00..=0x0f) — locks the zero-padding
contract that `{:02x}` provides.
- A single-byte input — locks that no trailing separator is emitted.
- The empty-slice base case.
Closes domcyrus#305
|
This PR, lik the other, is lacking any proof that this is a performance gain and or that the compiler does not optimize the current code to a single string write. |
|
@laundmo Fair ask. Ran a release-mode micro-bench locally to check both halves of the concern (perf gain + compiler folding). Bench keeps both shapes side-by-side so the comparison is apples-to-apples on the same host: fn old(id: &[u8]) -> String {
id.iter()
.map(|b| format!("{:02x}", b))
.collect::<Vec<String>>()
.join(":")
}
fn new(id: &[u8]) -> String {
if id.is_empty() { return String::new(); }
let mut out = String::with_capacity(id.len() * 3 - 1);
let mut first = true;
for b in id {
if !first { out.push(':'); }
let _ = write!(out, "{b:02x}");
first = false;
}
out
}
Output bytes identical ( So both legs of the concern resolve as expected:
|
|
@0xghost42 thanks for your PR, and @laundmo thanks for your input too. First of all I want to say that I appreciate the effort you put into making RustNet better a lot. The FTP detection in #266 was a nice addition, so please don't read this as anything against the work you're doing. On this one I've come to the conclusion that I'd rather not optimize cold paths and only do this kind of work on hot paths. The code here only runs once per QUIC connection, and for me, without digging into it a bit more, that's not performance critical. The code itself is fine and the tests are nice, so this is purely a call about where I want to spend optimization effort. For this purpose I also wrote the "Performance and Optimizations" section in CONTRIBUTING.md. The way I see it is that a microbenchmark shows a function got faster on its own but it doesn't show that the function actually matters to the runtime. I think for that I would want a flamegraph first. So I'm going to close this one, but genuinely thanks for the effort and the clear writeup. I would love to see the same energy on a path we know is hot. I hope you understand my reasoning. |
Summary
connection_id_to_hexwas rendering a QUIC connection ID through the worst-case allocation pattern:format!("{:02x}", b)allocated a 2-byteStringfor every input byte, the iterator collected them into aVec<String>(one more allocation), andjoin(":")then walked the vector to build the finalString. For an 8-byte short-form DCID that is 8 + 1 + 1 = 10 heap allocations per render.Replace with a single
String::with_capacity(id.len() * 3 - 1)(exact final size: 2 hex chars per byte + N-1 colon separators) and a per-bytewrite!. Writing into a pre-sizedStringdoes not reallocate, so the helper now performs exactly one heap allocation per call regardless of input length, except for the empty-slice case which returns an emptyString(no allocation). The lowercase, colon-separated output is unchanged.Tests
Four regression tests added:
{:02x}provides.Local checks:
cargo test --lib— 365 passed.cargo clippy --all-targets -- -D warnings— clean.cargo fmt --check— clean.Closes #305