Skip to content

Commit 240d833

Browse files
glbrnttjosephnoir
andauthored
Index the QPACK static table by name (#10)
Motivation: StaticHeaderTable.find does a linear scan of all 99 static table entries. It's called once per header by both the static and dynamic encoders. The scan relies on string comparisons for (potentially) every header name in the table which is where a lot of the cost lies. Modifications: - Add a static field name to index map. This is used by `find` to get all indexes for a given header in constant time. - Add benchmarks Result: Faster header encoding - static encoding benchmark: 2.8x faster - dynamic encoding benchmark: 1.3x faster --------- Co-authored-by: Raphael Hiesgen <github@hiesgen.dev>
1 parent 8119053 commit 240d833

4 files changed

Lines changed: 192 additions & 12 deletions

File tree

Benchmarks/Package.swift

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// swift-tools-version:6.3
2+
//===----------------------------------------------------------------------===//
3+
//
4+
// This source file is part of the SwiftNIO open source project
5+
//
6+
// Copyright (c) 2026 Apple Inc. and the SwiftNIO project authors
7+
// Licensed under Apache License v2.0
8+
//
9+
// See LICENSE.txt for license information
10+
// See CONTRIBUTORS.txt for the list of SwiftNIO project authors
11+
//
12+
// SPDX-License-Identifier: Apache-2.0
13+
//
14+
//===----------------------------------------------------------------------===//
15+
16+
import PackageDescription
17+
18+
let package = Package(
19+
name: "swift-nio-http3-benchmarks",
20+
platforms: [
21+
.macOS(.v13)
22+
],
23+
dependencies: [
24+
.package(path: "../"),
25+
.package(url: "https://github.com/apple/swift-http-types.git", from: "1.3.0"),
26+
.package(url: "https://github.com/apple/swift-nio-quic-helpers.git", branch: "main"),
27+
.package(url: "https://github.com/ordo-one/benchmark.git", from: "1.35.0"),
28+
],
29+
targets: [
30+
.executableTarget(
31+
name: "QPACKBenchmarks",
32+
dependencies: [
33+
.product(name: "NIOHTTP3", package: "swift-nio-http3"),
34+
.product(name: "HTTPTypes", package: "swift-http-types"),
35+
.product(name: "NIOQUICHelpers", package: "swift-nio-quic-helpers"),
36+
.product(name: "Benchmark", package: "benchmark"),
37+
],
38+
path: "QPACKBenchmarks",
39+
plugins: [
40+
.plugin(name: "BenchmarkPlugin", package: "benchmark")
41+
]
42+
)
43+
]
44+
)
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftNIO open source project
4+
//
5+
// Copyright (c) 2026 Apple Inc. and the SwiftNIO project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftNIO project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import Benchmark
16+
import HTTPTypes
17+
@_spi(Benchmarks) import NIOHTTP3
18+
import NIOQUICHelpers
19+
20+
/// A realistic request header set for a browser GET, exercising a mix of exact
21+
/// static-table matches (`:method GET`), name-only matches (`:authority`) and
22+
/// headers absent from the table (`x-custom-header`).
23+
private let requestHeaders: [HTTPField] = [
24+
HTTPField(name: .init(parsed: ":method")!, value: "GET"),
25+
HTTPField(name: .init(parsed: ":scheme")!, value: "https"),
26+
HTTPField(name: .init(parsed: ":authority")!, value: "www.example.com"),
27+
HTTPField(name: .init(parsed: ":path")!, value: "/index.html"),
28+
HTTPField(name: .init(parsed: "user-agent")!, value: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"),
29+
HTTPField(name: .init(parsed: "accept")!, value: "*/*"),
30+
HTTPField(name: .init(parsed: "accept-encoding")!, value: "gzip, deflate, br"),
31+
HTTPField(name: .init(parsed: "accept-language")!, value: "en-US,en;q=0.9"),
32+
HTTPField(name: .init(parsed: "cache-control")!, value: "no-cache"),
33+
HTTPField(name: .init(parsed: "cookie")!, value: "session=abc123def456; theme=dark"),
34+
HTTPField(name: .init(parsed: "referer")!, value: "https://www.example.com/"),
35+
HTTPField(name: .init(parsed: "x-custom-header")!, value: "some-custom-value"),
36+
]
37+
38+
let benchmarks: @Sendable () -> Void = {
39+
// Exercises StaticHeaderTable.find on every header, with no dynamic table.
40+
Benchmark(
41+
"QPACKStaticEncode_BrowserGET",
42+
configuration: .init(
43+
metrics: [.mallocCountTotal, .instructions, .wallClock],
44+
scalingFactor: .kilo
45+
)
46+
) { benchmark in
47+
for _ in benchmark.scaledIterations {
48+
blackHole(QPACKBenchmarks.staticEncode(headers: requestHeaders))
49+
}
50+
}
51+
52+
// Exercises the static-table lookup plus the dynamic table on every header.
53+
Benchmark(
54+
"QPACKDynamicEncode_BrowserGET",
55+
configuration: .init(
56+
metrics: [.mallocCountTotal, .instructions, .wallClock],
57+
scalingFactor: .kilo
58+
)
59+
) { benchmark in
60+
for _ in benchmark.scaledIterations {
61+
blackHole(
62+
QPACKBenchmarks.dynamicEncode(
63+
headers: requestHeaders,
64+
streamID: QUICStreamID(rawValue: 0),
65+
dynamicTableMaxCapacity: 4096,
66+
dynamicTableInitialCapacity: 4096,
67+
maxBlockedStreams: 100,
68+
targetEvictableFraction: 0.5
69+
)
70+
)
71+
}
72+
}
73+
}

Sources/NIOHTTP3/QPACK+SPI.swift

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftNIO open source project
4+
//
5+
// Copyright (c) 2026 Apple Inc. and the SwiftNIO project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftNIO project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
public import HTTPTypes
16+
public import NIOQUICHelpers
17+
private import QPACK
18+
19+
// QPACK isn't a library product, so add entry points for benchmarks here under SPI.
20+
@_spi(Benchmarks)
21+
public enum QPACKBenchmarks {
22+
/// Encode `headers` with the static-only encoder. Returns the number of field lines
23+
/// produced.
24+
///
25+
/// The return value doesn't matter, but needs to be there so the calling benchmark
26+
/// can stop the function call from being optimised away.
27+
@_spi(Benchmarks)
28+
public static func staticEncode(headers: [HTTPField]) -> Int {
29+
let encoder = StaticQPACKEncoder()
30+
return encoder.encode(headers: headers).lines.count
31+
}
32+
33+
/// Create a dynamic encoder and encode `headers` on `streamID`. Returns the number
34+
/// of field lines produced.
35+
///
36+
/// The return value doesn't matter, but needs to be there so the calling benchmark
37+
/// can stop the function call from being optimised away.
38+
@_spi(Benchmarks)
39+
public static func dynamicEncode(
40+
headers: [HTTPField],
41+
streamID: QUICStreamID,
42+
dynamicTableMaxCapacity: Int,
43+
dynamicTableInitialCapacity: Int,
44+
maxBlockedStreams: Int,
45+
targetEvictableFraction: Double
46+
) -> Int {
47+
var (encoder, _) = DynamicQPACKEncoder.create(
48+
dynamicTableMaxCapacity: dynamicTableMaxCapacity,
49+
dynamicTableInitialCapacity: dynamicTableInitialCapacity,
50+
maxBlockedStreams: maxBlockedStreams,
51+
targetEvictableFraction: targetEvictableFraction
52+
)
53+
return encoder.encode(headers: headers, forStream: streamID).fieldSection.lines.count
54+
}
55+
}

Sources/QPACK/Headers/StaticHeaderTable.swift

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,16 @@ package enum StaticHeaderTable {
121121
(.init(parsed: "x-frame-options")!, "sameorigin"), // 98
122122
]
123123

124+
/// A mapping of header name to the array of static table indices carrying that name.
125+
/// Arrays are guaranteed to be non-empty, and their values are in ascending index order.
126+
private static let indicesByName: [HTTPField.Name: [Int]] = {
127+
var result = [HTTPField.Name: [Int]](minimumCapacity: Self.shared.count)
128+
for index in Self.shared.indices {
129+
result[Self.shared[index].0, default: []].append(index)
130+
}
131+
return result
132+
}()
133+
124134
/// Get the element of the static table at the specific index if it exists
125135
package static func get(at index: Int) -> (HTTPField.Name, String)? {
126136
if self.shared.indices.contains(index) {
@@ -143,21 +153,19 @@ package enum StaticHeaderTable {
143153
/// parameter, an indication whether that value was also found. Returns `nil`
144154
/// if no matching header name could be located.
145155
static func find(name: HTTPField.Name, value: String?) -> (index: Int, containsValue: Bool)? {
146-
var nameOnlyMatchIndex: Int?
147-
for index in Self.shared.indices {
148-
let header = Self.shared[index]
149-
if header.0 == name {
150-
if header.1 == value {
156+
guard let indices = Self.indicesByName[name] else {
157+
return nil
158+
}
159+
160+
if let value = value {
161+
for index in indices {
162+
if Self.shared[index].1 == value {
151163
return (index: index, containsValue: true)
152-
} else if nameOnlyMatchIndex == nil {
153-
nameOnlyMatchIndex = index
154164
}
155165
}
156166
}
157-
if let nameOnlyMatchIndex {
158-
return (index: nameOnlyMatchIndex, containsValue: false)
159-
} else {
160-
return nil
161-
}
167+
168+
// No value (or no matching value), return the first index.
169+
return (index: indices[0], containsValue: false)
162170
}
163171
}

0 commit comments

Comments
 (0)