-
-
Notifications
You must be signed in to change notification settings - Fork 60
frameworks/httpbeast: add benchmark implementation #1175
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
067399d
frameworks/httpbeast: add benchmark implementation
nigrosimone 4f84115
frameworks/httpbeast: decode a chunked request body by hand, the serv…
nigrosimone c2f15eb
Merge remote-tracking branch 'upstream/main' into add-httpbeast
nigrosimone 4dd81dc
Benchmark results: httpbeast [skip ci]
github-actions[bot] File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| FROM nimlang/nim:2.2.4 AS build | ||
|
|
||
| WORKDIR /app | ||
| # zippy is the gzip codec for json-comp: httpbeast ships no compression, so | ||
| # the entry brings its own. | ||
| RUN nimble install -y zippy@0.10.19 | ||
|
|
||
| # httpbeast 0.4.2 is the server under test, pinned to the commit nimble | ||
| # resolves for that version (there is no v0.4.2 tag upstream). It is cloned | ||
| # instead of nimble-installed because a patch is applied first: upstream reads | ||
| # a request body by Content-Length only, so a chunked request is handed to the | ||
| # handler before its body arrived and with the header end position clobbered. | ||
| # httpbeast-chunked.patch makes the read loop wait for the zero size last | ||
| # chunk and keep the header end stable; the raw chunked bytes are then decoded | ||
| # by hand in main.nim. | ||
| RUN git clone https://github.com/dom96/httpbeast.git /httpbeast \ | ||
| && git -C /httpbeast checkout 75008aaba497479df4023d52880086deae6385e3 | ||
| COPY httpbeast-chunked.patch /httpbeast/ | ||
| RUN git -C /httpbeast apply httpbeast-chunked.patch | ||
|
|
||
| COPY main.nim . | ||
| RUN nim c -d:release --mm:orc --threads:on --opt:speed --path:/httpbeast/src -o:/app/server main.nim | ||
|
|
||
| # nimlang/nim:2.2.4 is Debian trixie and the binary links nothing but glibc, | ||
| # so the runtime image is the same distro with no compiler in it. | ||
| FROM debian:trixie-slim | ||
| COPY --from=build /app/server /server | ||
| EXPOSE 8080 | ||
| CMD ["/server"] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| # httpbeast | ||
|
|
||
| HttpBeast, the epoll HTTP server that Jester and Prologue are built on, called | ||
| directly. | ||
|
|
||
| ## Stack | ||
|
|
||
| - **Language:** Nim 2.2.4 | ||
| - **Framework:** HttpBeast 0.4.2, gzip from zippy 0.10.19 | ||
| - **Build:** `nimlang/nim:2.2.4`, `nim c -d:release --mm:orc --threads:on` | ||
|
|
||
| ## Endpoints | ||
|
|
||
| | Endpoint | Method | Description | | ||
| |----------|--------|-------------| | ||
| | `/pipeline` | GET | Returns `ok` (plain text) | | ||
| | `/baseline11` | GET | Sums query parameter values | | ||
| | `/baseline11` | POST | Sums query parameters + request body | | ||
| | `/json/{count}?m=N` | GET | First `count` dataset items with `total = price * quantity * m` | | ||
| | `/upload` | POST | Returns the byte count of the body | | ||
|
|
||
| ## Notes | ||
|
|
||
| - One httpbeast thread per available core, each with its own SO_REUSEPORT | ||
| listener, so there is no primary process to fan requests out | ||
| - The thread count comes from the cgroup CPU quota when there is one, and from | ||
| the host CPU count otherwise, the same way koa's `getCPUCount` does it | ||
| - Routing is a hand-written match on the request target, because httpbeast has | ||
| no router | ||
| - JSON is written by hand from a per-item prefix built at startup, so a request | ||
| only appends `total` and the closing brace | ||
| - Compression is gzip from zippy, negotiated per request on `Accept-Encoding`. | ||
| This is what makes the mode `tuned`: httpbeast has no compression middleware | ||
| - The dataset is read once before the threads start; a missing file leaves an | ||
| empty list instead of failing the boot | ||
| - httpbeast reads a request body by `Content-Length` only and would answer a | ||
| chunked request before the body arrived. The build applies | ||
| `httpbeast-chunked.patch` to the pinned source: the read loop waits for the | ||
| zero size last chunk and keeps the header end position stable. The handler | ||
| then gets the raw chunked bytes and `main.nim` decodes them by hand |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| diff --git a/src/httpbeast.nim b/src/httpbeast.nim | ||
| index af1dc26..3d398b5 100644 | ||
| --- a/src/httpbeast.nim | ||
| +++ b/src/httpbeast.nim | ||
| @@ -197,12 +197,54 @@ proc slowHeadersCheck(data: ptr Data): bool = | ||
|
|
||
| data.headersFinishPos = -1 | ||
|
|
||
| +proc hasChunkedEncoding(data: ptr Data): bool = | ||
| + # True when the header block declares Transfer-Encoding: chunked. | ||
| + let lower = data.data[0 ..< data.headersFinishPos].toLowerAscii() | ||
| + let te = lower.find("\c\ltransfer-encoding:") | ||
| + if te == -1: return false | ||
| + var lineEnd = lower.find("\c\l", te + 2) | ||
| + if lineEnd == -1: lineEnd = lower.len | ||
| + return lower[te ..< lineEnd].contains("chunked") | ||
| + | ||
| +proc chunkedBodyComplete(data: ptr Data): bool = | ||
| + # Walks the chunks buffered so far. The body is complete once the zero | ||
| + # size last chunk and its final CRLF are in the buffer. Trailers are not | ||
| + # supported, an empty trailer section is all the benchmark traffic has. | ||
| + var i = data.headersFinishPos | ||
| + while true: | ||
| + var size = 0 | ||
| + var digits = 0 | ||
| + while i < data.data.len: | ||
| + case data.data[i] | ||
| + of '0'..'9': size = size * 16 + (ord(data.data[i]) - ord('0')) | ||
| + of 'a'..'f': size = size * 16 + (ord(data.data[i]) - ord('a') + 10) | ||
| + of 'A'..'F': size = size * 16 + (ord(data.data[i]) - ord('A') + 10) | ||
| + else: break | ||
| + inc digits | ||
| + inc i | ||
| + if digits == 0 or digits > 15: return false | ||
| + # The rest of the size line (chunk extensions) up to its CRLF. | ||
| + while i < data.data.len and data.data[i] != '\l': inc i | ||
| + if i >= data.data.len: return false | ||
| + inc i | ||
| + if size == 0: | ||
| + # Zero chunk seen, the terminating CRLF must be buffered too. | ||
| + return data.data.len >= i + 2 | ||
| + # The chunk data and its CRLF must be buffered before the next size line. | ||
| + i += size + 2 | ||
| + if i > data.data.len: return false | ||
| + | ||
| proc bodyInTransit(data: ptr Data): bool = | ||
| assert methodNeedsBody(data), "Calling bodyInTransit now is inefficient." | ||
| assert data.headersFinished | ||
|
|
||
| if data.headersFinishPos == -1: return false | ||
|
|
||
| + if hasChunkedEncoding(data): | ||
| + # There is no Content-Length, the body ends at the zero size chunk. | ||
| + # The handler gets the raw chunked bytes and decodes them itself. | ||
| + return not chunkedBodyComplete(data) | ||
| + | ||
| var trueLen = parseContentLength(data.data, start=0) | ||
|
|
||
| let bodyLen = data.data.len - data.headersFinishPos | ||
| @@ -267,7 +309,12 @@ proc processEvents(selector: Selector[Data], | ||
| data.data.setLen(origLen + ret) | ||
| for i in 0 ..< ret: data.data[origLen+i] = buf[i] | ||
|
|
||
| - if data.data.len >= 4 and fastHeadersCheck(data) or slowHeadersCheck(data): | ||
| + # A method with a body must take the slow check: the fast one only | ||
| + # looks at the buffer tail, so the CRLFCRLF ending a chunked body | ||
| + # would move headersFinishPos past the body. | ||
| + if data.data.len >= 4 and | ||
| + (if methodNeedsBody(data): slowHeadersCheck(data) | ||
| + else: fastHeadersCheck(data)): | ||
| # First line and headers for request received. | ||
| data.headersFinished = true | ||
| when not defined(release): | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,227 @@ | ||
| ## HttpArena entry for HttpBeast, the epoll HTTP server behind most of the Nim | ||
| ## web stack (Jester and Prologue both run on it). | ||
| ## | ||
| ## HttpBeast gives one event loop per thread, each with its own SO_REUSEPORT | ||
| ## listener, so the thread count is the CPU count and there is no cluster | ||
| ## primary. Routing, JSON and gzip are written here because the server has | ||
| ## none of them: it hands over a parsed request and takes back a response. | ||
|
|
||
| import std/[asyncdispatch, options, os, strutils, json, cpuinfo] | ||
| import httpbeast | ||
| import zippy | ||
|
|
||
| const | ||
| hdrText = "Content-Type: text/plain" | ||
| hdrJson = "Content-Type: application/json" | ||
| hdrJsonGzip = "Content-Type: application/json\c\LContent-Encoding: gzip" | ||
|
|
||
| type Dataset = object | ||
| ## prefix[i] is the item JSON up to `"total":`, factor[i] is price*quantity. | ||
| ## A request only appends total and the closing brace. | ||
| prefix: seq[string] | ||
| factor: seq[int] | ||
|
|
||
| proc loadDataset(): Dataset = | ||
| let path = getEnv("DATASET_PATH", "/data/dataset.json") | ||
| var doc: JsonNode | ||
| try: | ||
| doc = parseJson(readFile(path)) | ||
| except CatchableError: | ||
| return # no dataset file: serve an empty list instead of dying | ||
| if doc.kind != JArray: | ||
| return | ||
| for item in doc: | ||
| if item.kind != JObject: | ||
| continue | ||
| var prefix = $item # compact, and keeps the file's field order | ||
| prefix.setLen(prefix.len - 1) # drop the closing brace | ||
| prefix.add(",\"total\":") | ||
| result.prefix.add(prefix) | ||
| result.factor.add(item{"price"}.getInt * item{"quantity"}.getInt) | ||
|
|
||
| # Read once, before any worker thread exists, and never written again. | ||
| let dataset = loadDataset() | ||
|
|
||
| proc parseIntIn(s: string, first, last: int, value: var int): bool = | ||
| ## Leading integer of s[first ..< last]. Never raises: a parameter that is | ||
| ## not a number is skipped, the same way the other entries skip a NaN. | ||
| var i = first | ||
| var negative = false | ||
| if i < last and (s[i] == '-' or s[i] == '+'): | ||
| negative = s[i] == '-' | ||
| inc i | ||
| var digits = 0 | ||
| var n = 0 | ||
| while i < last and s[i] in '0'..'9': | ||
| if digits < 18: | ||
| n = n * 10 + (ord(s[i]) - ord('0')) | ||
| inc digits | ||
| inc i | ||
| if digits == 0 or digits > 18: | ||
| return false | ||
| value = if negative: -n else: n | ||
| true | ||
|
|
||
| iterator queryPairs(target: string, qmark: int): (int, int, int) = | ||
| ## Yields (nameStart, nameEnd, valueEnd) for every `name=value` in the query. | ||
| if qmark >= 0: | ||
| var i = qmark + 1 | ||
| while i < target.len: | ||
| var stop = i | ||
| while stop < target.len and target[stop] != '&': inc stop | ||
| var eq = i | ||
| while eq < stop and target[eq] != '=': inc eq | ||
| if eq < stop: | ||
| yield (i, eq, stop) | ||
| i = stop + 1 | ||
|
|
||
| proc sumQuery(target: string, qmark: int): int = | ||
| for (nameStart, eq, stop) in queryPairs(target, qmark): | ||
| var v = 0 | ||
| if parseIntIn(target, eq + 1, stop, v): | ||
| result += v | ||
|
|
||
| proc queryInt(target: string, qmark: int, name: string, value: var int): bool = | ||
| for (nameStart, eq, stop) in queryPairs(target, qmark): | ||
| if eq - nameStart == name.len and target[nameStart ..< eq] == name: | ||
| return parseIntIn(target, eq + 1, stop, value) | ||
|
|
||
| proc jsonBody(count, m: int): string {.gcsafe.} = | ||
| # The cast is the dataset read: it is built before run() starts a thread and | ||
| # never written afterwards, which Nim cannot prove on a global. | ||
| {.cast(gcsafe).}: | ||
| var n = count | ||
| if n < 0: n = 0 | ||
| if n > dataset.prefix.len: n = dataset.prefix.len | ||
| var size = 24 | ||
| for i in 0 ..< n: | ||
| size += dataset.prefix[i].len + 24 | ||
| result = newStringOfCap(size) | ||
| result.add("{\"items\":[") | ||
| for i in 0 ..< n: | ||
| if i > 0: result.add(',') | ||
| result.add(dataset.prefix[i]) | ||
| result.addInt(dataset.factor[i] * m) | ||
| result.add('}') | ||
| result.add("],\"count\":") | ||
| result.addInt(n) | ||
| result.add('}') | ||
|
|
||
| proc acceptsGzip(req: Request): bool = | ||
| let headers = req.headers | ||
| if headers.isNone: | ||
| return false | ||
| let accepted: string = headers.get().getOrDefault("accept-encoding") | ||
| result = accepted.toLowerAscii().contains("gzip") | ||
|
|
||
| proc isChunked(req: Request): bool = | ||
| let headers = req.headers | ||
| if headers.isNone: | ||
| return false | ||
| let te: string = headers.get().getOrDefault("transfer-encoding") | ||
| result = te.toLowerAscii().contains("chunked") | ||
|
|
||
| proc dechunk(raw: string): string = | ||
| ## Chunked transfer decoding by hand: hex size line, that many bytes, CRLF, | ||
| ## until the zero size chunk. httpbeast only reads a body by Content-Length, | ||
| ## so the build patches it (httpbeast-chunked.patch) to buffer a chunked | ||
| ## payload until the last chunk and hand it over raw, decoding happens here. | ||
| var i = 0 | ||
| while i < raw.len: | ||
| var size = 0 | ||
| var digits = 0 | ||
| while i < raw.len: | ||
| case raw[i] | ||
| of '0'..'9': size = size * 16 + (ord(raw[i]) - ord('0')) | ||
| of 'a'..'f': size = size * 16 + (ord(raw[i]) - ord('a') + 10) | ||
| of 'A'..'F': size = size * 16 + (ord(raw[i]) - ord('A') + 10) | ||
| else: break | ||
| inc digits | ||
| inc i | ||
| if digits == 0 or digits > 15: break | ||
| # rest of the size line (chunk extensions) and its CRLF | ||
| while i < raw.len and raw[i] != '\l': inc i | ||
| inc i | ||
| if size == 0: break | ||
| if size > raw.len - i: size = raw.len - i # truncated payload | ||
| if size > 0: result.add(raw[i ..< i + size]) | ||
| i += size + 2 # chunk data and its CRLF | ||
|
|
||
| proc requestBody(req: Request): string = | ||
| let body = req.body | ||
| if body.isNone: | ||
| return "" | ||
| if isChunked(req): dechunk(body.get()) else: body.get() | ||
|
|
||
| proc onRequest(req: Request): Future[void] {.gcsafe.} = | ||
| let httpMethod = req.httpMethod | ||
| if httpMethod.isNone: | ||
| return | ||
| let target = req.path.get("") | ||
| var qmark = -1 | ||
| for i in 0 ..< target.len: | ||
| if target[i] == '?': | ||
| qmark = i | ||
| break | ||
| let route = if qmark < 0: target else: target[0 ..< qmark] | ||
|
|
||
| case httpMethod.get() | ||
| of HttpGet: | ||
| if route == "/pipeline": | ||
| req.send(Http200, "ok", hdrText) | ||
| elif route == "/baseline11": | ||
| req.send(Http200, $sumQuery(target, qmark), hdrText) | ||
| elif route.len > 6 and route.startsWith("/json/"): | ||
| var count = 0 | ||
| discard parseIntIn(route, 6, route.len, count) | ||
| var m = 0 | ||
| if not queryInt(target, qmark, "m", m) or m == 0: | ||
| m = 1 | ||
| let body = jsonBody(count, m) | ||
| # json-comp: httpbeast has no compression middleware, so gzip is done | ||
| # here with zippy, only when the client asked for it. | ||
| if acceptsGzip(req): | ||
| req.send(Http200, compress(body, DefaultCompression, dfGzip), hdrJsonGzip) | ||
| else: | ||
| req.send(Http200, body, hdrJson) | ||
| else: | ||
| req.send(Http404) | ||
| of HttpPost: | ||
| if route == "/baseline11": | ||
| var total = sumQuery(target, qmark) | ||
| let text = requestBody(req).strip() | ||
| var n = 0 | ||
| if parseIntIn(text, 0, text.len, n): | ||
| total += n | ||
| req.send(Http200, $total, hdrText) | ||
| elif route == "/upload": | ||
| req.send(Http200, $requestBody(req).len, hdrText) | ||
| else: | ||
| req.send(Http404) | ||
| else: | ||
| req.send(Http404) | ||
|
|
||
| proc cpuCount(): int = | ||
| ## Same shape as koa's getCPUCount: the cgroup quota when there is one, | ||
| ## the host CPU count otherwise. | ||
| try: | ||
| let parts = readFile("/sys/fs/cgroup/cpu.max").strip().split(' ') | ||
| if parts.len == 2 and parts[0] != "max": | ||
| let n = parseInt(parts[0]) div parseInt(parts[1]) | ||
| if n >= 1: return n | ||
| except CatchableError: | ||
| discard | ||
| try: | ||
| let quota = parseInt(readFile("/sys/fs/cgroup/cpu/cpu.cfs_quota_us").strip()) | ||
| let period = parseInt(readFile("/sys/fs/cgroup/cpu/cpu.cfs_period_us").strip()) | ||
| if quota > 0 and period > 0 and quota div period >= 1: | ||
| return quota div period | ||
| except CatchableError: | ||
| discard | ||
| result = countProcessors() | ||
| if result < 1: | ||
| result = 1 | ||
|
|
||
| when isMainModule: | ||
| run(onRequest, initSettings(port = Port(8080), bindAddr = "0.0.0.0", | ||
| numThreads = cpuCount())) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| { | ||
| "display_name": "httpbeast", | ||
| "language": "Nim", | ||
| "type": "flagship", | ||
| "mode": "tuned", | ||
| "engine": "httpbeast", | ||
| "description": "HttpBeast 0.4.2 called directly, the epoll server Jester and Prologue are both built on. One event loop per core, each thread with its own SO_REUSEPORT listener, no primary process. Routing, JSON and gzip are written in the entry because the server ships none of them: json-comp gzips through zippy on Accept-Encoding, which is what makes the mode tuned. Nim 2.2.4, -d:release with ORC.", | ||
| "repo": "https://github.com/dom96/httpbeast", | ||
| "enabled": true, | ||
| "tests": [ | ||
| "baseline", | ||
| "pipelined", | ||
| "limited-conn", | ||
| "json", | ||
| "json-comp", | ||
| "upload" | ||
| ], | ||
| "maintainers": [] | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
xD We can't merge this, merge this patch on the main repo