Skip to content

Commit 27b730e

Browse files
perf: reuse HPACK encode buffer across frames (#929)
`into_encoding` allocated a fresh `BytesMut` for every HEADERS and PUSH_PROMISE frame, grew it through several capacity doublings while HPACK encoded into it, then called `.freeze()` — allocating again for the `Bytes` refcount header. That is at least two allocations per outbound header block, on every request and every response. The block does not need to outlive the call: on the common path it is copied into the connection write buffer and dropped immediately. So `hpack::Encoder` now keeps one scratch buffer that `into_encoding` takes and `encode` hands back once the block has been written. `EncodingHeaderBlock::hpack` becomes `BytesMut`, which removes the `.freeze()` as well. The CONTINUATION path keeps its remainder instead of returning the buffer, and splitting that remainder is now `split_to` on a uniquely-owned buffer rather than a copy. Same bytes on the wire; no public API change. `cargo bench --bench main`, 5 interleaved rounds per arm: multi-thread 100k requests improves 3.8 % (faster in 24 of 25 pairwise comparisons, exact permutation test p = 0.016), current-thread 1.5 %, and the write-contention benchmark is unchanged as expected.
1 parent c2d3fb6 commit 27b730e

2 files changed

Lines changed: 45 additions & 11 deletions

File tree

src/frame/headers.rs

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use crate::hpack::{self, BytesStr};
66
use http::header::{self, HeaderName, HeaderValue};
77
use http::{uri, HeaderMap, Method, Request, StatusCode, Uri};
88

9-
use bytes::{Buf, BufMut, Bytes, BytesMut};
9+
use bytes::{Buf, BufMut, BytesMut};
1010

1111
use std::fmt;
1212
use std::io::Cursor;
@@ -104,7 +104,7 @@ struct HeaderBlock {
104104

105105
#[derive(Debug)]
106106
struct EncodingHeaderBlock {
107-
hpack: Bytes,
107+
hpack: BytesMut,
108108
}
109109

110110
const END_STREAM: u8 = 0x1;
@@ -287,7 +287,7 @@ impl Headers {
287287

288288
self.header_block
289289
.into_encoding(encoder)
290-
.encode(&head, dst, |_| {})
290+
.encode(&head, dst, Some(encoder), |_| {})
291291
}
292292

293293
fn head(&self) -> Head {
@@ -508,7 +508,7 @@ impl PushPromise {
508508

509509
self.header_block
510510
.into_encoding(encoder)
511-
.encode(&head, dst, |dst| {
511+
.encode(&head, dst, Some(encoder), |dst| {
512512
dst.put_u32(promised_id.into());
513513
})
514514
}
@@ -551,7 +551,7 @@ impl Continuation {
551551
// Get the CONTINUATION frame head
552552
let head = self.head();
553553

554-
self.header_block.encode(&head, dst, |_| {})
554+
self.header_block.encode(&head, dst, None, |_| {})
555555
}
556556
}
557557

@@ -647,7 +647,13 @@ impl Pseudo {
647647
// ===== impl EncodingHeaderBlock =====
648648

649649
impl EncodingHeaderBlock {
650-
fn encode<F>(mut self, head: &Head, dst: &mut EncodeBuf<'_>, f: F) -> Option<Continuation>
650+
fn encode<F>(
651+
mut self,
652+
head: &Head,
653+
dst: &mut EncodeBuf<'_>,
654+
encoder: Option<&mut hpack::Encoder>,
655+
f: F,
656+
) -> Option<Continuation>
651657
where
652658
F: FnOnce(&mut EncodeBuf<'_>),
653659
{
@@ -664,14 +670,20 @@ impl EncodingHeaderBlock {
664670

665671
// Now, encode the header payload
666672
let continuation = if self.hpack.len() > dst.remaining_mut() {
667-
dst.put((&mut self.hpack).take(dst.remaining_mut()));
673+
let head_part = self.hpack.split_to(dst.remaining_mut());
674+
dst.put_slice(&head_part);
668675

669676
Some(Continuation {
670677
stream_id: head.stream_id(),
671678
header_block: self,
672679
})
673680
} else {
674681
dst.put_slice(&self.hpack);
682+
// The block is fully written, so the buffer can be reused by the
683+
// next frame on this connection.
684+
if let Some(encoder) = encoder {
685+
encoder.return_scratch(self.hpack);
686+
}
675687

676688
None
677689
};
@@ -978,17 +990,16 @@ impl HeaderBlock {
978990
}
979991

980992
fn into_encoding(self, encoder: &mut hpack::Encoder) -> EncodingHeaderBlock {
981-
let mut hpack = BytesMut::new();
993+
let mut hpack = encoder.take_scratch();
994+
hpack.clear();
982995
let headers = Iter {
983996
pseudo: Some(self.pseudo),
984997
fields: self.fields.into_iter(),
985998
};
986999

9871000
encoder.encode(headers, &mut hpack);
9881001

989-
EncodingHeaderBlock {
990-
hpack: hpack.freeze(),
991-
}
1002+
EncodingHeaderBlock { hpack }
9921003
}
9931004

9941005
/// Calculates the size of the currently decoded header list.

src/hpack/encoder.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@ use http::header::{HeaderName, HeaderValue};
88
pub struct Encoder {
99
table: Table,
1010
size_update: Option<SizeUpdate>,
11+
/// Reusable buffer for the encoded header block of a single frame.
12+
///
13+
/// A header block only has to live until it is copied into the
14+
/// connection write buffer, so the buffer that holds it can be reused
15+
/// across frames instead of being allocated and freed per frame. See
16+
/// `take_scratch` / `return_scratch`.
17+
scratch: BytesMut,
1118
}
1219

1320
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
@@ -21,9 +28,25 @@ impl Encoder {
2128
Encoder {
2229
table: Table::new(max_size, capacity),
2330
size_update: None,
31+
scratch: BytesMut::new(),
2432
}
2533
}
2634

35+
/// Takes the reusable buffer for one header-block encode.
36+
///
37+
/// The buffer is returned by `return_scratch` once the encoded block has
38+
/// been copied into the write buffer. If it is not returned - the
39+
/// CONTINUATION path keeps it, since the remainder is still needed - the
40+
/// next call simply starts from a fresh buffer.
41+
pub(crate) fn take_scratch(&mut self) -> BytesMut {
42+
std::mem::take(&mut self.scratch)
43+
}
44+
45+
/// Returns a fully-written header block buffer for reuse.
46+
pub(crate) fn return_scratch(&mut self, scratch: BytesMut) {
47+
self.scratch = scratch;
48+
}
49+
2750
/// Queues a max size update.
2851
///
2952
/// The next call to `encode` will include a dynamic size update frame.

0 commit comments

Comments
 (0)