Skip to main content

bench_parse/
bench_parse.rs

1//! Parse cost per request head: simoom-http1 with SIMD and with scalar scanning, against the
2//! cameleer-http parsing logic (copied below, unchanged in substance, so there is no
3//! dependency on it).
4//!
5//! ```text
6//! cargo run --release -p simoom-http1 --example bench_parse
7//! ```
8
9use simoom_http1::write::{DateCache, HeadWriter, StaticHead};
10use simoom_http1::*;
11use simoom_sys::scan::{force, level, Level};
12use std::hint::black_box;
13use std::time::Instant;
14
15const CURL: &[u8] = b"GET / HTTP/1.1\r\nHost: localhost:8080\r\nUser-Agent: curl/8.5.0\r\nAccept: */*\r\n\r\n";
16
17const BROWSER: &[u8] = b"GET /index.html?q=1 HTTP/1.1\r\nHost: www.example.com\r\nConnection: keep-alive\r\nsec-ch-ua: \"Chromium\";v=\"130\", \"Google Chrome\";v=\"130\", \"Not?A_Brand\";v=\"99\"\r\nsec-ch-ua-mobile: ?0\r\nsec-ch-ua-platform: \"Windows\"\r\nUpgrade-Insecure-Requests: 1\r\nUser-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36\r\nAccept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7\r\nSec-Fetch-Site: none\r\nSec-Fetch-Mode: navigate\r\nSec-Fetch-User: ?1\r\nSec-Fetch-Dest: document\r\nAccept-Encoding: gzip, deflate, br, zstd\r\nAccept-Language: en-US,en;q=0.9\r\nCookie: session=8f2a9c1d4e5b6a7f8091a2b3c4d5e6f7; theme=dark; _ga=GA1.1.123456789.1700000000\r\n\r\n";
18
19fn twenty_headers() -> Vec<u8> {
20    let mut s = b"POST /api/v1/orders HTTP/1.1\r\nHost: api.internal:8443\r\nContent-Type: application/json\r\nContent-Length: 128\r\n".to_vec();
21    for i in 0..17 {
22        s.extend_from_slice(format!("X-Custom-Header-{i}: value-{i}-abcdefghijklmnop\r\n").as_bytes());
23    }
24    s.extend_from_slice(b"\r\n");
25    s
26}
27
28/// Best of several rounds, in nanoseconds per call.
29fn measure(mut f: impl FnMut()) -> f64 {
30    let mut iters = 1_000u64;
31    loop {
32        let t = Instant::now();
33        for _ in 0..iters {
34            f();
35        }
36        if t.elapsed().as_millis() >= 50 {
37            break;
38        }
39        iters *= 4;
40    }
41    (0..7)
42        .map(|_| {
43            let t = Instant::now();
44            for _ in 0..iters {
45                f();
46            }
47            t.elapsed().as_nanos() as f64 / iters as f64
48        })
49        .fold(f64::INFINITY, f64::min)
50}
51
52fn simoom(req: &[u8]) -> usize {
53    let mut hs = [EMPTY_HEADER; 32];
54    match parse_request(black_box(req), &mut hs, &Limits::default()) {
55        Ok(Status::Complete(r)) => r.head_len + r.headers.len(),
56        other => panic!("{other:?}"),
57    }
58}
59
60fn main() {
61    let twenty = twenty_headers();
62    let cases: [(&str, &[u8]); 3] = [("curl (3 fields)", CURL), ("browser (15 fields)", BROWSER), ("20 fields", &twenty)];
63    let best = level();
64    println!("scan level detected: {best:?}");
65    println!("{:<22} {:>6} {:>12} {:>12} {:>14} {:>9}", "request", "bytes", "simd ns", "scalar ns", "cameleer ns", "GB/s simd");
66    for (name, req) in cases {
67        assert!(cameleer::parse(req).is_ok());
68        force(best);
69        let simd = measure(|| {
70            black_box(simoom(req));
71        });
72        force(Level::Scalar);
73        let scalar = measure(|| {
74            black_box(simoom(req));
75        });
76        force(best);
77        let cam = measure(|| {
78            black_box(cameleer::parse(black_box(req)).ok());
79        });
80        println!(
81            "{name:<22} {:>6} {simd:>12.1} {scalar:>12.1} {cam:>14.1} {:>9.2}",
82            req.len(),
83            req.len() as f64 / simd
84        );
85    }
86    // Serialization.
87    let date = DateCache::new(1_700_000_000);
88    let mut out = Vec::with_capacity(256);
89    let head = measure(|| {
90        out.clear();
91        let mut w = HeadWriter::response(&mut out, 200);
92        w.header(b"Content-Type", b"application/json").unwrap().date(&date).content_length(1234);
93        w.finish();
94        black_box(&out);
95    });
96    let mut fixed = StaticHead::new(200, &[(b"Content-Type", b"text/plain"), (b"Server", b"simoom")], b"Hello, World!").unwrap();
97    let mut secs = 1_700_000_000;
98    let mut dc = DateCache::new(secs);
99    let stat = measure(|| {
100        black_box(fixed.full(&dc).len());
101    });
102    let stat_tick = measure(|| {
103        secs += 1;
104        dc.update(secs);
105        black_box(fixed.full(&dc).len());
106    });
107    println!("HeadWriter (status, 1 field, Date, Content-Length): {head:.1} ns");
108    println!("StaticHead::full (fixed reply, same second): {stat:.1} ns; with a new second every call: {stat_tick:.1} ns");
109}
110
111/// The request-head parsing path of `cameleer-http` (`crates/cameleer-http/src/http.rs`:
112/// `read_line`, `read_head`, `body_kind`, the request-line split, Host check and target
113/// handling), copied here so the comparison has no dependency. It reads through `BufRead` and
114/// builds owned `String`s, as the original does.
115mod cameleer {
116    // Copied as is: some fields are built but not read here, exactly as in the original.
117    #![allow(dead_code)]
118
119    use std::io::BufRead;
120
121    #[derive(Debug)]
122    pub enum HttpError {
123        BadRequest(String),
124        HeaderTooLarge,
125        UnexpectedEof,
126        NotImplemented(String),
127        PayloadTooLarge,
128        Io,
129    }
130
131    #[derive(Default)]
132    pub struct HttpHeaders {
133        entries: Vec<(String, String)>,
134    }
135
136    impl HttpHeaders {
137        fn get_all(&self, name: &str) -> Vec<&str> {
138            self.entries.iter().filter(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v.as_str()).collect()
139        }
140        fn contains(&self, name: &str) -> bool {
141            self.entries.iter().any(|(k, _)| k.eq_ignore_ascii_case(name))
142        }
143    }
144
145    pub struct HttpRequest {
146        pub method: String,
147        pub target: String,
148        pub headers: HttpHeaders,
149    }
150
151    #[derive(Debug)]
152    pub enum BodyKind {
153        None,
154        Length(usize),
155        Chunked,
156    }
157
158    fn read_line<R: BufRead>(r: &mut R, budget: &mut usize) -> Result<Option<String>, HttpError> {
159        let mut buf = Vec::new();
160        loop {
161            let available = r.fill_buf().map_err(|_| HttpError::Io)?;
162            if available.is_empty() {
163                if buf.is_empty() {
164                    return Ok(None);
165                }
166                return Err(HttpError::UnexpectedEof);
167            }
168            let (consume, done) = match available.iter().position(|&b| b == b'\n') {
169                Some(i) => (i + 1, true),
170                None => (available.len(), false),
171            };
172            if consume > *budget {
173                return Err(HttpError::HeaderTooLarge);
174            }
175            *budget -= consume;
176            buf.extend_from_slice(&available[..consume]);
177            r.consume(consume);
178            if done {
179                break;
180            }
181        }
182        buf.pop();
183        if buf.last() == Some(&b'\r') {
184            buf.pop();
185        }
186        String::from_utf8(buf).map(Some).map_err(|_| HttpError::BadRequest("invalid UTF-8 in header".into()))
187    }
188
189    fn is_token(s: &str) -> bool {
190        !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&b))
191    }
192
193    fn read_head<R: BufRead>(r: &mut R) -> Result<Option<(String, HttpHeaders)>, HttpError> {
194        let mut budget = 16 * 1024;
195        let start = loop {
196            match read_line(r, &mut budget)? {
197                None => return Ok(None),
198                Some(l) if l.is_empty() => continue,
199                Some(l) => break l,
200            }
201        };
202        let mut headers = HttpHeaders::default();
203        loop {
204            let line = read_line(r, &mut budget)?.ok_or(HttpError::UnexpectedEof)?;
205            if line.is_empty() {
206                break;
207            }
208            if line.starts_with(' ') || line.starts_with('\t') {
209                return Err(HttpError::BadRequest("obsolete header line folding".into()));
210            }
211            let colon = line.find(':').ok_or_else(|| HttpError::BadRequest(format!("malformed header: {}", line)))?;
212            let name = &line[..colon];
213            if !is_token(name) {
214                return Err(HttpError::BadRequest(format!("invalid header name: {}", name)));
215            }
216            if headers.entries.len() >= 100 {
217                return Err(HttpError::HeaderTooLarge);
218            }
219            headers.entries.push((name.to_string(), line[colon + 1..].trim().to_string()));
220        }
221        Ok(Some((start, headers)))
222    }
223
224    fn body_kind(headers: &HttpHeaders) -> Result<BodyKind, HttpError> {
225        if let Some(te) = headers.get_all("Transfer-Encoding").last().copied() {
226            let last = te.split(',').next_back().map(|s| s.trim().to_ascii_lowercase()).unwrap_or_default();
227            if last == "chunked" {
228                return Ok(BodyKind::Chunked);
229            }
230            return Err(HttpError::NotImplemented(format!("Transfer-Encoding: {}", te)));
231        }
232        let lengths = headers.get_all("Content-Length");
233        if !lengths.is_empty() {
234            let mut value: Option<usize> = None;
235            for l in lengths {
236                for part in l.split(',') {
237                    let part = part.trim();
238                    if part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()) {
239                        return Err(HttpError::BadRequest(format!("invalid Content-Length: {}", l)));
240                    }
241                    let n: usize = part.parse().map_err(|_| HttpError::PayloadTooLarge)?;
242                    if value.is_some_and(|v| v != n) {
243                        return Err(HttpError::BadRequest("conflicting Content-Length values".into()));
244                    }
245                    value = Some(n);
246                }
247            }
248            return Ok(BodyKind::Length(value.unwrap_or(0)));
249        }
250        Ok(BodyKind::None)
251    }
252
253    pub fn parse(mut input: &[u8]) -> Result<(HttpRequest, BodyKind), HttpError> {
254        let (start, headers) = read_head(&mut input)?.ok_or(HttpError::UnexpectedEof)?;
255        let mut parts = start.split(' ').filter(|s| !s.is_empty());
256        let (method, target, version) = match (parts.next(), parts.next(), parts.next(), parts.next()) {
257            (Some(m), Some(t), Some(v), None) => (m, t, v),
258            _ => return Err(HttpError::BadRequest(format!("malformed request line: {}", start))),
259        };
260        if !is_token(method) {
261            return Err(HttpError::BadRequest(format!("invalid method: {}", method)));
262        }
263        if version != "HTTP/1.1" && version != "HTTP/1.0" {
264            return Err(HttpError::BadRequest(format!("unsupported version: {}", version)));
265        }
266        if version == "HTTP/1.1" && !headers.contains("Host") {
267            return Err(HttpError::BadRequest("missing Host header".into()));
268        }
269        let target = if let Some(rest) = target.strip_prefix("http://").or_else(|| target.strip_prefix("https://")) {
270            match rest.find('/') {
271                Some(i) => rest[i..].to_string(),
272                None => "/".to_string(),
273            }
274        } else {
275            target.to_string()
276        };
277        if !target.starts_with('/') && target != "*" {
278            return Err(HttpError::BadRequest(format!("invalid request target: {}", target)));
279        }
280        let kind = body_kind(&headers)?;
281        Ok((HttpRequest { method: method.to_string(), target, headers }, kind))
282    }
283}