Skip to content

vanilla: DB path — prepared statement (async-db) + single-pass HTML escape#878

Closed
enghitalo wants to merge 1 commit into
MDA2AV:mainfrom
enghitalo:perf/vanilla-db-prepared
Closed

vanilla: DB path — prepared statement (async-db) + single-pass HTML escape#878
enghitalo wants to merge 1 commit into
MDA2AV:mainfrom
enghitalo:perf/vanilla-db-prepared

Conversation

@enghitalo

Copy link
Copy Markdown
Contributor

Summary

Two DB-path improvements in frameworks/vanilla-{epoll,io_uring}/main.v:

  1. Prepared statement for async-db. exec_param_many (PQexecParams) re-parses the SQL server-side on every request. Switched to a PostgreSQL prepared statement (PQprepare + PQexecPrepared via db.pg), lazily prepared on each pooled connection's first use — prepared statements are per-session and the pool reuses sessions, so after warmup every connection serves exec_prepared.
  2. Single-pass HTML escape (fortunes). escape_html did five full-string replace_each passes; now it's one pass with a fast path that returns the message untouched (no allocation) when there's nothing to escape.

Measured (local, 16-core loopback, gcannon, single listener)

profile before after Δ
fortunes ~5.6K ~7.1K +27% (escape rewrite)
async-db ~18.0K ~19.6K +9% (prepared statement)

Honest scope

These are incremental. The DB profiles remain bound by the stdlib db.pg driver (text protocol, lazy pool) — confirmed by veb (same V + db.pg) being slower than vanilla on async-db. The top DB engines (swerver, genhttp) use native binary-protocol drivers with persistent prepared statements + pipelining; closing that gap needs a faster pg driver (a separate, larger project), not framework tweaks. Correctness verified (async-db rows + count; fortunes renders the same rows). Both epoll and io_uring variants.

🤖 Generated with Claude Code

… escape

Two DB-path improvements (both backends):

1. async-db now uses a PostgreSQL prepared statement (PQprepare/PQexecPrepared
   via db.pg) instead of exec_param_many (PQexecParams), which re-parses the SQL
   server-side on every request. Lazily prepared on each pooled connection's
   first use — prepared statements are per-session and the pool reuses sessions,
   so after warmup every connection serves exec_prepared.

2. escape_html (fortunes) does ONE pass with a fast path (return the message
   untouched, no allocation, when it has no special chars) instead of
   replace_each's five full-string scans + reallocations.

Local before/after (16-core loopback, gcannon, single listener):
  fortunes  ~5.6K -> ~7.1K req/s  (+27%, from the escape rewrite)
  async-db  ~18.0K -> ~19.6K req/s (+9%, from the prepared statement)

These are incremental: the DB profiles remain bound by the stdlib db.pg driver
(text protocol, lazy pool) — the top DB engines use native binary-protocol
drivers — so this narrows the gap without closing it. Correctness verified
(async-db rows + count correct; fortunes renders the same rows).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@enghitalo

Copy link
Copy Markdown
Contributor Author

/benchmark -f vanilla-epoll --save

@github-actions

Copy link
Copy Markdown
Contributor

👋 /benchmark request received. A collaborator will review and approve the run.

@enghitalo

Copy link
Copy Markdown
Contributor Author

Folded into #877 so everything lands in a single PR (json-comp cache + zero-alloc routing/keys + DB prepared statement + single-pass HTML escape).

@github-actions

Copy link
Copy Markdown
Contributor

Benchmark Results

Framework: vanilla-epoll | Test: all tests

Test Conn RPS CPU Mem Δ RPS Δ Mem
baseline 512 457,961 1084.0% 72MiB -1.6% ~0%
baseline 4096 438,637 1094.0% 194MiB +2.2% +0.5%
pipelined 512 2,359,046 850.6% 53MiB -0.9% ~0%
pipelined 4096 2,323,696 873.6% 187MiB +1.0% ~0%
limited-conn 512 203,357 681.2% 61MiB -6.2% -1.6%
limited-conn 4096 305,753 994.7% 179MiB +0.8% ~0%
json 4096 498,117 1732.8% 184MiB +2.8% +4.0%
json-comp 512 49,097 721.1% 86MiB +0.3% -1.1%
json-comp 4096 52,233 738.0% 186MiB -8.5% +0.5%
json-comp 16384 70,085 1045.2% 702MiB -0.9% -0.3%
upload 32 45 154.3% 815MiB +50.0% -3.4%
upload 256 73 163.6% 2.3GiB +30.4% +64.3%
api-4 256 31,173 344.3% 74MiB -5.1% +4.2%
api-16 1024 19,779 370.1% 97MiB +0.2% +4.3%
static 1024 318,624 6556.8% 711MiB +0.1% -4.4%
static 4096 264,958 6260.7% 2.8GiB -0.5% ~0%
static 6800 205,553 5308.4% 4.5GiB -1.5% ~0%
async-db 1024 10,990 352.6% 101MiB +1.1% +1.0%
crud 4096 140,273 1090.5% 404MiB +17.3% +5.8%
fortunes 1024 3,855 215.1% 64MiB +45.2% -55.6%
Full log
[run 1/3]
gcannon v0.5.3
  Target:    localhost:8080/
  Threads:   64
  Conns:     4096 (64/thread)
  Pipeline:  1
  Req/conn:  200
  Templates: 20
  Expected:  200
  Duration:  15s


  Thread Stats   Avg      p50      p90      p99    p99.9
    Latency   29.84ms   13.70ms   78.80ms   240.40ms   416.10ms

  2051907 requests in 15.00s, 2051717 responses
  Throughput: 136.76K req/s
  Bandwidth:  42.74MB/s
  Status codes: 2xx=2051717, 3xx=0, 4xx=0, 5xx=0
  Latency samples: 2051717 / 2051717 responses (100.0%)
  Reconnects: 8303
  Per-template: 99218,102376,104792,103645,102645,104836,106165,106264,104685,102953,105889,106743,107319,108079,107147,102901,97369,89215,91819,97657
  Per-template-ok: 99218,102376,104792,103645,102645,104836,106165,106264,104685,102953,105889,106743,107319,108079,107147,102901,97369,89215,91819,97657
[info] CPU 1029.5% | Mem 399MiB

[run 2/3]
gcannon v0.5.3
  Target:    localhost:8080/
  Threads:   64
  Conns:     4096 (64/thread)
  Pipeline:  1
  Req/conn:  200
  Templates: 20
  Expected:  200
  Duration:  15s


  Thread Stats   Avg      p50      p90      p99    p99.9
    Latency   29.13ms   12.70ms   77.70ms   237.60ms   386.70ms

  2104095 requests in 15.00s, 2104096 responses
  Throughput: 140.25K req/s
  Bandwidth:  43.94MB/s
  Status codes: 2xx=2104096, 3xx=0, 4xx=0, 5xx=0
  Latency samples: 2104094 / 2104096 responses (100.0%)
  Reconnects: 8569
  Per-template: 102312,104579,107481,108139,107981,108594,111144,110428,107386,106348,105249,107614,105311,105530,105337,106660,102420,94689,96951,99941
  Per-template-ok: 102312,104579,107481,108139,107981,108594,111144,110428,107386,106348,105249,107614,105311,105530,105337,106660,102420,94689,96951,99941
[info] CPU 1090.5% | Mem 404MiB

[run 3/3]
gcannon v0.5.3
  Target:    localhost:8080/
  Threads:   64
  Conns:     4096 (64/thread)
  Pipeline:  1
  Req/conn:  200
  Templates: 20
  Expected:  200
  Duration:  15s


  Thread Stats   Avg      p50      p90      p99    p99.9
    Latency   29.49ms   12.90ms   78.90ms   233.30ms   383.70ms

  2080038 requests in 15.00s, 2080038 responses
  Throughput: 138.65K req/s
  Bandwidth:  43.32MB/s
  Status codes: 2xx=2080038, 3xx=0, 4xx=0, 5xx=0
  Latency samples: 2080034 / 2080038 responses (100.0%)
  Reconnects: 8492
  Per-template: 104661,103475,103047,103925,105424,106488,109369,107474,107245,107540,105346,103426,104155,106855,107189,105049,100676,92649,95012,101029
  Per-template-ok: 104661,103475,103047,103925,105424,106488,109369,107474,107245,107540,105346,103426,104155,106855,107189,105049,100676,92649,95012,101029
[info] CPU 1097.5% | Mem 411MiB

=== Best: 140273 req/s (CPU: 1090.5%, Mem: 404MiB) ===
[info] input BW: 12.04MB/s (avg template: 90 bytes)
[info] saved results/crud/4096/vanilla-epoll.json
httparena-bench-vanilla-epoll
httparena-bench-vanilla-epoll

==============================================
=== vanilla-epoll / fortunes / 1024c (tool=gcannon) ===
==============================================
[info] resetting postgres for a clean per-profile baseline
[info] starting postgres sidecar
httparena-postgres
[info] postgres ready (seeded)
[info] waiting for server...
[info] server ready

[run 1/3]
gcannon v0.5.3
  Target:    localhost:8080/fortunes
  Threads:   64
  Conns:     1024 (16/thread)
  Pipeline:  1
  Req/conn:  unlimited (keep-alive)
  Expected:  200
  Duration:  5s


  Thread Stats   Avg      p50      p90      p99    p99.9
    Latency   6.15ms   6.57ms   8.63ms   13.30ms   32.60ms

  19276 requests in 5.00s, 19276 responses
  Throughput: 3.85K req/s
  Bandwidth:  91.38MB/s
  Status codes: 2xx=19276, 3xx=0, 4xx=0, 5xx=0
  Latency samples: 19276 / 19276 responses (100.0%)
[info] CPU 215.1% | Mem 64MiB

[run 2/3]
gcannon v0.5.3
  Target:    localhost:8080/fortunes
  Threads:   64
  Conns:     1024 (16/thread)
  Pipeline:  1
  Req/conn:  unlimited (keep-alive)
  Expected:  200
  Duration:  5s


  Thread Stats   Avg      p50      p90      p99    p99.9
    Latency   332.06ms   273.20ms   685.80ms    1.15s    1.67s

  14686 requests in 5.00s, 14686 responses
  Throughput: 2.94K req/s
  Bandwidth:  69.62MB/s
  Status codes: 2xx=14686, 3xx=0, 4xx=0, 5xx=0
  Latency samples: 14686 / 14686 responses (100.0%)
[info] CPU 473.3% | Mem 136MiB

[run 3/3]
gcannon v0.5.3
  Target:    localhost:8080/fortunes
  Threads:   64
  Conns:     1024 (16/thread)
  Pipeline:  1
  Req/conn:  unlimited (keep-alive)
  Expected:  200
  Duration:  5s


  Thread Stats   Avg      p50      p90      p99    p99.9
    Latency   329.99ms   268.40ms   689.70ms    1.19s    1.52s

  14786 requests in 5.00s, 14786 responses
  Throughput: 2.96K req/s
  Bandwidth:  70.09MB/s
  Status codes: 2xx=14786, 3xx=0, 4xx=0, 5xx=0
  Latency samples: 14786 / 14786 responses (100.0%)
[info] CPU 471.8% | Mem 137MiB

=== Best: 3855 req/s (CPU: 215.1%, Mem: 64MiB) ===
[info] saved results/fortunes/1024/vanilla-epoll.json
httparena-bench-vanilla-epoll
httparena-bench-vanilla-epoll
[info] skip: vanilla-epoll does not subscribe to baseline-h2
[info] skip: vanilla-epoll does not subscribe to static-h2
[info] skip: vanilla-epoll does not subscribe to baseline-h2c
[info] skip: vanilla-epoll does not subscribe to json-h2c
[info] skip: vanilla-epoll does not subscribe to baseline-h3
[info] skip: vanilla-epoll does not subscribe to static-h3
[info] skip: vanilla-epoll does not subscribe to gateway-64
[info] skip: vanilla-epoll does not subscribe to gateway-h3
[info] skip: vanilla-epoll does not subscribe to production-stack
[info] skip: vanilla-epoll does not subscribe to unary-grpc
[info] skip: vanilla-epoll does not subscribe to unary-grpc-tls
[info] skip: vanilla-epoll does not subscribe to stream-grpc
[info] skip: vanilla-epoll does not subscribe to stream-grpc-tls
[info] skip: vanilla-epoll does not subscribe to echo-ws
[info] skip: vanilla-epoll does not subscribe to echo-ws-pipeline
[info] rebuilding site/data/*.json
[updated] /home/diogo/actions-runner/_work/HttpArena/HttpArena/site/data/frameworks.json
[updated] /home/diogo/actions-runner/_work/HttpArena/HttpArena/site/data/api-16-1024.json
[updated] /home/diogo/actions-runner/_work/HttpArena/HttpArena/site/data/api-4-256.json
[updated] /home/diogo/actions-runner/_work/HttpArena/HttpArena/site/data/async-db-1024.json
[updated] /home/diogo/actions-runner/_work/HttpArena/HttpArena/site/data/baseline-4096.json
[updated] /home/diogo/actions-runner/_work/HttpArena/HttpArena/site/data/baseline-512.json
[updated] /home/diogo/actions-runner/_work/HttpArena/HttpArena/site/data/crud-4096.json
[updated] /home/diogo/actions-runner/_work/HttpArena/HttpArena/site/data/fortunes-1024.json
[updated] /home/diogo/actions-runner/_work/HttpArena/HttpArena/site/data/json-4096.json
[updated] /home/diogo/actions-runner/_work/HttpArena/HttpArena/site/data/json-comp-16384.json
[updated] /home/diogo/actions-runner/_work/HttpArena/HttpArena/site/data/json-comp-4096.json
[updated] /home/diogo/actions-runner/_work/HttpArena/HttpArena/site/data/json-comp-512.json
[updated] /home/diogo/actions-runner/_work/HttpArena/HttpArena/site/data/limited-conn-4096.json
[updated] /home/diogo/actions-runner/_work/HttpArena/HttpArena/site/data/limited-conn-512.json
[updated] /home/diogo/actions-runner/_work/HttpArena/HttpArena/site/data/pipelined-4096.json
[updated] /home/diogo/actions-runner/_work/HttpArena/HttpArena/site/data/pipelined-512.json
[updated] /home/diogo/actions-runner/_work/HttpArena/HttpArena/site/data/static-1024.json
[updated] /home/diogo/actions-runner/_work/HttpArena/HttpArena/site/data/static-4096.json
[updated] /home/diogo/actions-runner/_work/HttpArena/HttpArena/site/data/static-6800.json
[updated] /home/diogo/actions-runner/_work/HttpArena/HttpArena/site/data/upload-256.json
[updated] /home/diogo/actions-runner/_work/HttpArena/HttpArena/site/data/upload-32.json
[updated] /home/diogo/actions-runner/_work/HttpArena/HttpArena/site/data/current.json
[info] done
httparena-postgres
httparena-redis
[info] restoring loopback MTU to 65536

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant