Skip to content

[perf] TLS handshake rate plateaus at ~640/s and HTTPS costs ~35% throughput vs HTTP #306

Closed

Description

@gg582

Summary

Measured CWIST HTTPS performance on a 12-core Linux loopback box. HTTPS costs ~35% of throughput vs plaintext on the same handler, and full TLS 1.3 handshakes plateau at ~640 handshakes/sec regardless of client concurrency (no scaling past ~P32). That is low for a 12-core machine — nginx/OpenSSL-class servers do several thousand full handshakes/sec.

Measured numbers

Benchmark server: cwist_app_use_https() with example/othello-web/server.{crt,key}, one route returning a 20-byte JSON. Tooling: wrk -t4 -c100 -d10s, curl --no-keepalive for handshake rate.

Throughput (keep-alive):

Server req/sec p50 p99
CWIST HTTP :8080 290,755 139 µs 4.01 ms
CWIST HTTPS :8443 190,067 250 µs 4.28 ms

Full TLS 1.3 handshakes/sec (curl, loopback):

Concurrency CWIST HTTPS openssl s_server (same cert)
P8 ~515 —
P32 ~643 ~212
P64 ~635 (plateau) —

CWIST beats the sequential openssl s_server baseline but stops scaling at ~640/s, suggesting the handshake pipeline (not crypto) is the bottleneck — consistent with open issue #294.

Code findings (src/net/http/https.c)

Good: cert/key loaded once into SSL_CTX (no per-request reload); session resumption + shared pre-fork ticket keys enabled; non-blocking handshakes on sharded epoll shepherd threads.

Suspected bottlenecks:

  1. Handshake shard ceiling: default CWIST_HTTPS_HS_MAX_SHARDS 16 (https.c:541). Fixed shard count likely explains the ~640/s plateau. See [perf] Evaluate raising HTTPS handshake shard default #294.
  2. 16 KiB read buffer (include/cwist/net/http/http.h:138) — every SSL_read caps at 16 KiB (https.c:1477-1478); no SSL_set_buffer_size/BIO enlargement anywhere, so large request bodies need many small encrypted reads.
  3. Connection teardown cost: every connection does SSL_shutdown + up to 16×2 KiB of recv(MSG_DONTWAIT) draining + close (https.c:1383-1399) — thousands of extra syscalls/sec at handshake-churn rates (relates to Performance: reduce syscalls and serialization overhead to close the Actix gap #297).
  4. Blocking handshake fallback: non-Linux path does a blocking SSL_accept retry loop with poll waits (https.c:1309-1346).

Suggested next steps

  • Raise/autotune handshake shard count; re-measure handshakes/sec vs shards.
  • Enlarge TLS read buffer for large bodies.
  • Reduce teardown syscalls (skip/shorten shutdown drain, reuse connections).
  • Add a CI perf gate for HTTPS throughput + handshake rate so this regresses loudly.

Environment: Linux x86_64, 12 cores, loopback, CWIST built via make libcwist.a (vendored BoringSSL), test cert from example/othello-web.

Activity

  1. gg582 commented on Oct 4, 2026

    @gg582
    MemberAuthor

    Update: root cause identified — the ~640 handshakes/s plateau was NOT shard-count or crypto limited. It was a Nagle/delayed-ACK stall: clients without TCP_NODELAY hold the HTTP request behind Nagle, and the kernel re-enters pingpong mode during the TLS handshake so the one-shot TCP_QUICKACK set at accept time no longer applies, delaying the client's request ~40ms. Fix (re-arm TCP_QUICKACK around handshake) in PR #307: 705 → 7,199 conn/s at 32 clients, plaintext control unchanged.

  2. gg582 commented on Oct 10, 2026

    @gg582
    MemberAuthor

    Fixed by #307 (TCP_QUICKACK re-arm) and guarded by the HTTPS perf gates (#309, calibrated and green). Churn went from ~640/s plateau to 5-7k conn/s; RTT stall 43ms -> 0.2ms.

  3. gg582 commented on Oct 10, 2026

    @gg582
    MemberAuthor

    Nagle again. ew.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions