From eb1052f8407af863aac9861003e5e5a442f4d377 Mon Sep 17 00:00:00 2001 From: Nigro Simone Date: Sat, 15 Aug 2026 21:20:29 +0200 Subject: [PATCH] frameworks/kemal: add benchmark implementation --- frameworks/kemal/Dockerfile | 16 +++ frameworks/kemal/README.md | 33 +++++ frameworks/kemal/meta.json | 19 +++ frameworks/kemal/shard.lock | 18 +++ frameworks/kemal/shard.yml | 15 +++ frameworks/kemal/src/server.cr | 216 +++++++++++++++++++++++++++++++++ 6 files changed, 317 insertions(+) create mode 100644 frameworks/kemal/Dockerfile create mode 100644 frameworks/kemal/README.md create mode 100644 frameworks/kemal/meta.json create mode 100644 frameworks/kemal/shard.lock create mode 100644 frameworks/kemal/shard.yml create mode 100644 frameworks/kemal/src/server.cr diff --git a/frameworks/kemal/Dockerfile b/frameworks/kemal/Dockerfile new file mode 100644 index 000000000..c88d61695 --- /dev/null +++ b/frameworks/kemal/Dockerfile @@ -0,0 +1,16 @@ +FROM crystallang/crystal:1.21.0 AS build +WORKDIR /app +COPY shard.yml shard.lock ./ +RUN shards install --production --frozen +COPY src ./src +RUN crystal build src/server.cr -o /app/server --release --no-debug + +# Same distribution as the build image, so the binary finds the libraries it +# was linked against. Only the shared libraries Crystal needs, no toolchain. +FROM ubuntu:24.04 +RUN apt-get update && \ + apt-get install -y --no-install-recommends libssl3t64 libpcre2-8-0 zlib1g && \ + rm -rf /var/lib/apt/lists/* +COPY --from=build /app/server /server +EXPOSE 8080 +CMD ["/server"] diff --git a/frameworks/kemal/README.md b/frameworks/kemal/README.md new file mode 100644 index 000000000..86ce82fa1 --- /dev/null +++ b/frameworks/kemal/README.md @@ -0,0 +1,33 @@ +# kemal + +Kemal on the Crystal `HTTP::Server`, default configuration. + +## Stack + +- **Language:** Crystal 1.21 +- **Framework:** Kemal 1.12 +- **Build:** `crystallang/crystal:1.21.0` in release mode, binary shipped on `ubuntu:24.04` + +## 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 | Reads the body and returns the byte count | + +## Notes + +- Routing and path parameters through the Kemal radix tree router +- JSON written straight into the response with `JSON::Builder`, so the items are never held twice +- Compression through the `HTTP::CompressHandler` that `gzip true` installs, with its defaults +- One process per core, all of them accepting on port 8080 with `SO_REUSEPORT`, because a Crystal + program serves on a single thread +- The worker count is read from the cgroup files first, so both `--cpus` and `--cpuset-cpus` size it + right, and only falls back to the host core count +- The dataset is read at startup from `DATASET_PATH` or `/data/dataset.json`. A missing file serves + an empty list instead of stopping the server +- The two POST endpoints read the request stream themselves, so nothing goes through the Kemal param + parser and the 20 MB uploads are never buffered diff --git a/frameworks/kemal/meta.json b/frameworks/kemal/meta.json new file mode 100644 index 000000000..4469ed660 --- /dev/null +++ b/frameworks/kemal/meta.json @@ -0,0 +1,19 @@ +{ + "display_name": "kemal", + "language": "Crystal", + "type": "flagship", + "mode": "standard", + "engine": "crystal-http", + "description": "Kemal 1.12 on the Crystal HTTP::Server, default configuration, one process per core sharing port 8080 through SO_REUSEPORT. Routing through the Kemal radix tree, JSON written into the response with JSON::Builder, gzip through the handler that gzip true installs.", + "repo": "https://github.com/kemalcr/kemal", + "enabled": true, + "tests": [ + "baseline", + "pipelined", + "limited-conn", + "json", + "json-comp", + "upload" + ], + "maintainers": [] +} diff --git a/frameworks/kemal/shard.lock b/frameworks/kemal/shard.lock new file mode 100644 index 000000000..e2789ee4e --- /dev/null +++ b/frameworks/kemal/shard.lock @@ -0,0 +1,18 @@ +version: 2.0 +shards: + backtracer: + git: https://github.com/sija/backtracer.cr.git + version: 1.2.4 + + exception_page: + git: https://github.com/crystal-loot/exception_page.git + version: 0.5.0 + + kemal: + git: https://github.com/kemalcr/kemal.git + version: 1.12.0 + + radix: + git: https://github.com/luislavena/radix.git + version: 0.4.1 + diff --git a/frameworks/kemal/shard.yml b/frameworks/kemal/shard.yml new file mode 100644 index 000000000..04d058d2c --- /dev/null +++ b/frameworks/kemal/shard.yml @@ -0,0 +1,15 @@ +name: kemal-arena +version: 1.0.0 + +targets: + server: + main: src/server.cr + +dependencies: + kemal: + github: kemalcr/kemal + version: 1.12.0 + +crystal: ">= 1.12.0" + +license: MIT diff --git a/frameworks/kemal/src/server.cr b/frameworks/kemal/src/server.cr new file mode 100644 index 000000000..6350c8c98 --- /dev/null +++ b/frameworks/kemal/src/server.cr @@ -0,0 +1,216 @@ +require "kemal" + +# ── Dataset ───────────────────────────────────────────────────────────────── + +struct Rating + include JSON::Serializable + + getter score : Int64 + getter count : Int64 +end + +struct Item + include JSON::Serializable + + getter id : Int64 + getter name : String + getter category : String + getter price : Int64 + getter quantity : Int64 + getter active : Bool + getter tags : Array(String) + getter rating : Rating + + # Stored fields as they are, rating still nested, then the computed total. + def write(json : JSON::Builder, m : Int64) : Nil + json.object do + json.field "id", @id + json.field "name", @name + json.field "category", @category + json.field "price", @price + json.field "quantity", @quantity + json.field "active", @active + json.field "tags", @tags + json.field "rating", @rating + json.field "total", @price * @quantity * m + end + end +end + +# A missing or unreadable file serves an empty list, it never stops the server. +def load_items : Array(Item) + Array(Item).from_json(File.read(ENV["DATASET_PATH"]? || "/data/dataset.json")) +rescue + [] of Item +end + +ITEMS = load_items + +# ── Worker count ──────────────────────────────────────────────────────────── +# +# Crystal serves on one thread, so the server runs one process per core and +# they all accept on the same port through SO_REUSEPORT. The count comes from +# the container limits first, the host only when there is no limit set. + +private def read_first_line(path : String) : String? + File.read(path).strip +rescue + nil +end + +# cgroup v2 cpu.max, then the v1 pair. This is what `docker run --cpus` sets. +private def cgroup_quota : Int32? + if line = read_first_line("/sys/fs/cgroup/cpu.max") + parts = line.split(' ') + if parts.size == 2 && parts[0] != "max" + quota = parts[0].to_i64? + period = parts[1].to_i64? + if quota && period && quota > 0 && period > 0 + n = (quota // period).to_i32 + return n if n >= 1 + end + end + end + + quota = read_first_line("/sys/fs/cgroup/cpu/cpu.cfs_quota_us").try &.to_i64? + period = read_first_line("/sys/fs/cgroup/cpu/cpu.cfs_period_us").try &.to_i64? + if quota && period && quota > 0 && period > 0 + n = (quota // period).to_i32 + return n if n >= 1 + end + + nil +end + +# The cpu list of the cgroup, "0-31,64-95" style. This is `--cpuset-cpus`, +# which the benchmark uses and which the quota above does not cover. +private def cgroup_cpuset : Int32? + list = read_first_line("/sys/fs/cgroup/cpuset.cpus.effective") || + read_first_line("/sys/fs/cgroup/cpuset/cpuset.cpus") + return nil if list.nil? || list.empty? + + total = 0 + list.split(',') do |part| + if part.includes?('-') + low, _, high = part.partition('-') + l = low.to_i? + h = high.to_i? + total += h - l + 1 if l && h && h >= l + elsif part.to_i? + total += 1 + end + end + total > 0 ? total : nil +end + +def worker_count : Int32 + cgroup_quota || cgroup_cpuset || System.cpu_count.to_i32 +end + +# ── Routes ────────────────────────────────────────────────────────────────── + +def sum_query(params : HTTP::Params) : Int64 + sum = 0_i64 + params.each do |_, value| + if n = value.to_i64? + sum += n + end + end + sum +end + +get "/pipeline" do |env| + env.response.content_type = "text/plain" + "ok" +end + +get "/baseline11" do |env| + env.response.content_type = "text/plain" + sum_query(env.params.query).to_s +end + +post "/baseline11" do |env| + total = sum_query(env.params.query) + # Read the raw stream: the body is a bare number, not a form or a document + if body = env.request.body + if n = body.gets_to_end.strip.to_i64? + total += n + end + end + env.response.content_type = "text/plain" + total.to_s +end + +get "/json/:count" do |env| + count = env.params.url["count"].to_i? || 0 + count = 0 if count < 0 + count = ITEMS.size if count > ITEMS.size + m = env.params.query["m"]?.try(&.to_i64?) || 1_i64 + + env.response.content_type = "application/json" + # Straight into the response, so the items are never held twice in memory. + # gzip, when the request asks for it, is the handler `gzip true` installs. + JSON.build(env.response) do |json| + json.object do + json.field "items" do + json.array do + count.times { |i| ITEMS[i].write(json, m) } + end + end + json.field "count", count + end + end + nil +end + +post "/upload" do |env| + received = 0_i64 + if body = env.request.body + buffer = Bytes.new(64 * 1024) + while (read = body.read(buffer)) > 0 + received += read + end + end + env.response.content_type = "text/plain" + received.to_s +end + +# ── Server ────────────────────────────────────────────────────────────────── + +def serve + logging false + serve_static false + gzip true + Kemal.config.env = "production" + Kemal.config.powered_by_header = false + + Kemal.run do |config| + # Binding here keeps Kemal from binding itself, which is how the workers + # get reuse_port and share the accept queue of port 8080. + config.server.not_nil!.bind_tcp("0.0.0.0", 8080, reuse_port: true) + end +end + +workers = worker_count + +if ENV["KEMAL_WORKER"]? || workers <= 1 + serve +else + children = Array(Process).new(workers) + workers.times do + children << Process.new( + Process.executable_path || "/server", + env: {"KEMAL_WORKER" => "1"}, + input: Process::Redirect::Inherit, + output: Process::Redirect::Inherit, + error: Process::Redirect::Inherit + ) + end + + Process.on_terminate do + children.each { |child| child.terminate rescue nil } + exit + end + + children.each { |child| child.wait rescue nil } +end