test: compare native concurrency and server memory
This commit is contained in:
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
- 新增 native 真实 TCP Keep-Alive 并发 benchmark,纳入稳定性与性能验收脚本,用于评估后续事件状态机优化。
|
- 统一标准库与 native 的真实 TCP benchmark,增加固定并发档位、响应状态与读写错误检查。
|
||||||
|
- 新增独立进程并发与长连接内存测量工具,记录成功吞吐、P95/P99、服务端分配、空闲连接堆内存和回收情况;修正并行 `ns/op` 与客户端/服务端混合分配的解释。
|
||||||
|
|
||||||
## v0.5.0
|
## v0.5.0
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,62 @@
|
|||||||
# GoOmni 性能基线
|
# GoOmni 性能基线
|
||||||
|
|
||||||
这份基线用于比较未来自研网络层,不代表生产容量承诺。标准库 benchmark 测进程内请求分发;native benchmark 额外覆盖真实 TCP、Keep-Alive 和 GoOmni 请求链,但不包含 TLS、数据库或业务存储延迟。
|
这轮测量显示 native 与标准库处于相同吞吐量级,在本机短时测试中,native 的空闲长连接堆内存更少。当前 native 仅实现 HTTP 的部分能力,尚不能据此判断生产容量或替代完整标准库服务端。
|
||||||
|
|
||||||
运行:
|
## 运行方式
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
# race、vet、build,以及进程内和真实 TCP benchmark
|
||||||
python3 scripts/verifyPerformance.py
|
python3 scripts/verifyPerformance.py
|
||||||
|
|
||||||
|
# 另加独立服务进程的并发、延迟和空闲长连接内存矩阵
|
||||||
|
python3 scripts/verifyPerformance.py --load --output /tmp/goOmniPerf.json
|
||||||
|
|
||||||
|
# 只运行独立进程矩阵,默认每档 2 秒、3 轮
|
||||||
|
go run ./internal/perfcheck --duration 2s --count 3 --output /tmp/goOmniPerf.json
|
||||||
```
|
```
|
||||||
|
|
||||||
本次记录环境:Go 1.25.0、darwin/arm64、Apple M2 Max;每项连续运行 3 次:
|
可通过 `--concurrency 1,32,128` / `--idle 128,1024` 调整矩阵;传空字符串可跳过对应场景。工具只监听本机随机端口,每个场景启动独立服务进程,并在结束后关闭客户端、检查连接排空、关闭服务。响应状态、完整响应体、requestId 或传输错误均计入失败,不能把快速返回的错误页算成成功吞吐。
|
||||||
|
|
||||||
```text
|
## 2026-09-21 本机测量
|
||||||
BenchmarkTransportRouteDispatch/nethttp 568–671 ns/op 1344 B/op 13 allocs/op
|
|
||||||
BenchmarkTransportApplicationRequest/nethttp 2017–2208 ns/op 3091 B/op 34 allocs/op
|
|
||||||
BenchmarkNativeApplicationRequest 15438–16711 ns/op 7855–7874 B/op 100 allocs/op
|
|
||||||
```
|
|
||||||
|
|
||||||
`BenchmarkTransportRouteDispatch` 只覆盖标准库路由、路径参数和 `http.Handler` 调用;`BenchmarkTransportApplicationRequest` 还包含 GoOmni Context、RequestID、统一 JSON 响应和默认中间件链。
|
环境:Go 1.25.0、darwin/arm64、Apple M2 Max、GOMAXPROCS=12。网络实现来自 `c36bb70` 工作树(native 与 v0.5.0 相同);本次新增测量工具,不修改服务端实现。原始测量值见 [tcpBaseline.json](performance/tcpBaseline.json)。报告时间为 UTC,北京时间是 9 月 21 日。
|
||||||
`BenchmarkNativeApplicationRequest` 启动 native TCP listener,通过复用连接的 HTTP client 并发请求健康接口,包含 socket、请求解析、连接状态管理、响应写回和对象池路径。它用于观察网络层的相对变化,不应直接换算为生产 QPS。
|
|
||||||
|
|
||||||
比较新网络层时,应使用相同 Go 版本、机器和 benchmark 命令,并同时运行 `-race` 全量测试。性能下降需要结合分配次数、取消传播、错误响应和业务契约结果一起判断。
|
每个并发档位持续约 2 秒、重复 3 轮,每轮交替标准库/native 的先后顺序。下表为各指标的三轮中位数;全部流量场景共完成 **2,660,828** 次请求,错误 **0**。另有 128 / 1,024 条空闲长连接的建立、保持和回收测试。
|
||||||
|
|
||||||
|
| 并发请求 | 标准库成功请求/秒 | native 成功请求/秒 | 标准库 P95 | native P95 | 标准库 P99 | native P99 |
|
||||||
|
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||||
|
| 1 | 19,731 | 21,497 | 0.074 ms | 0.070 ms | 0.139 ms | 0.119 ms |
|
||||||
|
| 32 | 90,099 | 88,812 | 1.016 ms | 0.983 ms | 1.507 ms | 1.507 ms |
|
||||||
|
| 128 | 112,422 | 112,105 | 3.277 ms | 3.408 ms | 4.719 ms | 4.981 ms |
|
||||||
|
|
||||||
|
128 并发时,两者吞吐差约 0.3%,native 的尾延迟略高。基于这组结果,暂时没有理由仅为健康接口吞吐就改成手写事件循环。
|
||||||
|
|
||||||
|
| 空闲 Keep-Alive 连接 | 标准库新增堆内存 | native 新增堆内存 | 标准库约每连接 | native 约每连接 |
|
||||||
|
| --- | ---: | ---: | ---: | ---: |
|
||||||
|
| 128 | 1.275 MiB | 0.658 MiB | 10,444 B | 5,389 B |
|
||||||
|
| 1,024 | 9.871 MiB | 5.137 MiB | 10,108 B | 5,260 B |
|
||||||
|
|
||||||
|
1,024 条空闲连接时,native 的新增堆内存约少 48%。这是**服务端 Go 堆内存增量**,不包括 goroutine 栈、进程其他内存和内核 socket 缓冲区,不能解释为整个进程内存减半。同档服务端 `StackInuse` 分别约为标准库 8.75 MiB、native 8.69 MiB,原始报告单独记录。
|
||||||
|
|
||||||
|
连接断开后,两者连接数都回到 0,goroutine 分别回到 2 / 3 的初始基线。一次 GC 后仍有对象池缓存;第二次 GC 后,总堆内存中位数回到约 2.10 / 2.08 MiB(启动基线约 1.54 MiB)。堆内存不会立刻精确回到启动数值,不把缓存保留解释为泄漏,也不把这轮短测解释为长期无泄漏证明。
|
||||||
|
|
||||||
|
128 并发时,**服务端单独累计分配**约为标准库 4,454 B/请求、47 次/请求,native 4,297 B/请求、55 次/请求。native 的分配字节较少、次数较多;具体来源需用分配 profile 确认,不能仅凭总量归因于 HTTP 桥接或 JSON。
|
||||||
|
|
||||||
|
## 统计边界
|
||||||
|
|
||||||
|
- 同一台机器上,压测客户端和服务端是两个独立进程。两者使用同一健康接口、相同小型 JSON 响应、固定 `requestId=perfCheck` 和 Keep-Alive;不启用访问日志、TLS 或数据库。
|
||||||
|
- 并发模型为闭环:每个 worker 等上一个请求完成后发下一个。实际经过时间包含最后请求排空;吞吐只计算成功响应。不能据此推断开放到达流量下的排队或过载表现。
|
||||||
|
- 延迟从客户端发起请求到完整响应校验完成,包含失败请求。直方图使用每个 2 的幂区间内 16 档,报告桶上界,舍入误差至多约 6.25%。小幅差别不应过度解释。
|
||||||
|
- 服务端 `runtime.MemStats` 通过独立 stdin/stdout 控制通道读取,客户端内存不计入。累计分配差值包含少量采样开销。堆峰值每 200 ms 采样,可能错过瞬时峰值;这些指标不是进程 RSS。
|
||||||
|
- 空闲场景在所有连接完成健康请求后做 GC 再采样。新增堆内存包括连接、缓冲区及分摊的运行时开销。`after` 为客户端断开并排空后一次 GC,`afterReclaim` 为第二次 GC,用于观察对象池缓存释放。
|
||||||
|
- native 配置上限为 8,192 连接 / 4,096 活跃请求;本轮并发低于上限,未验证过载保护容量。标准库没有同样的并发闸门;比较包括各自真实实现的开销。
|
||||||
|
|
||||||
|
## benchmark 口径修正
|
||||||
|
|
||||||
|
`BenchmarkTransportTCPApplicationRequest/{nethttp,native}/concurrency{1,32,128}` 使用同一测试入口、客户端、应用与响应检查。原先独立的 `BenchmarkNativeApplicationRequest` 已合并,避免两个入口配置漂移。`verifyPerformance.py` 会一并运行。
|
||||||
|
|
||||||
|
并行 benchmark 的 **`ns/op` 是总经过时间除以请求数,反映吞吐成本,不是单请求延迟**。`B/op` / `allocs/op` 统计同一进程中的客户端与服务端总分配。此前报告的 native 15.4–16.7 µs/op、约 7.8 KB/op、100 allocs/op 应按这个含义理解,不能作为服务端延迟或独立内存指标。
|
||||||
|
|
||||||
|
`BenchmarkTransportRouteDispatch` 和 `BenchmarkTransportApplicationRequest` 继续保留,分别测进程内路由分发和完整框架处理链;不包含 TCP,不能直接与上述网络延迟比较。
|
||||||
|
|
||||||
|
后续优先补 Linux 目标环境、持续压测、大请求体/慢客户端、连接反复建立回收和协议边界验证,再通过 profile 决定是否优化对象分配或事件状态机。
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ python3 scripts/verifyBusiness.py --version v0.5.0
|
|||||||
|
|
||||||
该验收会在临时目录中先固定 `v0.2.0`,再升级到 `v0.5.0`,检查无本地 replace、配置加载、结构化日志、requestId、CRUD、校验错误、业务错误和优雅退出。
|
该验收会在临时目录中先固定 `v0.2.0`,再升级到 `v0.5.0`,检查无本地 replace、配置加载、结构化日志、requestId、CRUD、校验错误、业务错误和优雅退出。
|
||||||
|
|
||||||
标准库路由和完整 GoOmni 请求链的可重复性能基线见 [PERFORMANCE.md](PERFORMANCE.md),运行 `python3 scripts/verifyPerformance.py` 可重新采样。
|
标准库与 native 的并发、延迟和长连接内存对照见 [PERFORMANCE.md](PERFORMANCE.md)。`python3 scripts/verifyPerformance.py` 运行检查与 benchmark;增加 `--load --output /tmp/goOmniPerf.json` 可测量独立服务进程并保存报告。
|
||||||
|
|
||||||
在框架根目录运行:
|
在框架根目录运行:
|
||||||
|
|
||||||
|
|||||||
@@ -39,9 +39,10 @@
|
|||||||
|
|
||||||
- 已加入标准库路由和完整 GoOmni 请求链 benchmark,输出分配次数和耗时,作为未来自研网络层的对照基线。
|
- 已加入标准库路由和完整 GoOmni 请求链 benchmark,输出分配次数和耗时,作为未来自研网络层的对照基线。
|
||||||
- 新增 `transport/native` 第一版自研 HTTP/1.1 服务端:自行管理 TCP listener、Keep-Alive、Content-Length 请求解析、响应写回、读写超时和优雅关闭;通过 `App.RunWith` 接入,业务 Handler / Service 保持不变。
|
- 新增 `transport/native` 第一版自研 HTTP/1.1 服务端:自行管理 TCP listener、Keep-Alive、Content-Length 请求解析、响应写回、读写超时和优雅关闭;通过 `App.RunWith` 接入,业务 Handler / Service 保持不变。
|
||||||
- 新增 native 真实 TCP Keep-Alive 并发 benchmark,记录 socket、解析、响应和对象池路径的耗时与分配,后续压测以此作为事件状态机改造前的基线。
|
- 标准库与 native 共用 TCP benchmark,固定 1 / 32 / 128 并发;相同应用、客户端、超时和响应检查,避免两个测试入口逐渐失去可比性。
|
||||||
|
- 新增独立服务进程压测 `internal/perfcheck`,记录成功吞吐、P50/P95/P99、服务端分配、采样堆峰值,以及 128 / 1,024 条空闲长连接的堆内存、goroutine 和断开后的回收情况。原始报告保存在 `performance/tcpBaseline.json`。
|
||||||
- native 当前拒绝 chunked 请求体,仍使用标准 `http.Request` / `http.ResponseWriter` 作为桥接;解析器、连接抽象和生产压测将在后续阶段继续下沉。
|
- native 当前拒绝 chunked 请求体,仍使用标准 `http.Request` / `http.ResponseWriter` 作为桥接;解析器、连接抽象和生产压测将在后续阶段继续下沉。
|
||||||
- 已具备全量 race、vet、build 验收;生产压测、长连接规模和真实部署验证仍待完成。
|
- 已具备全量 race、vet、build 验收;生产压测、长连接规模和真实部署验证仍待完成。
|
||||||
- 自动验收入口:`python3 scripts/verifyPerformance.py`。
|
- 自动验收入口:`python3 scripts/verifyPerformance.py`;增加 `--load --output /tmp/goOmniPerf.json` 可重复运行独立进程并发与内存矩阵。
|
||||||
|
|
||||||
以上阶段以验收结果判断完成。当前仍是基础框架试用阶段;MySQL、适配层契约和一次业务模块试用已完成,自研网络层已有可运行预览,稳定性与生产规模验证尚未完成。
|
以上阶段以验收结果判断完成。当前仍是基础框架试用阶段;MySQL、适配层契约和一次业务模块试用已完成,自研网络层已有可运行预览,稳定性与生产规模验证尚未完成。
|
||||||
|
|||||||
586
internal/perfcheck/main.go
Normal file
586
internal/perfcheck/main.go
Normal file
@@ -0,0 +1,586 @@
|
|||||||
|
// perfcheck compares real TCP servers in separate processes. It is a local
|
||||||
|
// measurement tool, not part of the framework's public CLI or server API.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/bits"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/transport/native"
|
||||||
|
)
|
||||||
|
|
||||||
|
const expectedBody = `{"code":"OK","message":"success","data":{"status":"ok"},"requestId":"perfCheck"}`
|
||||||
|
|
||||||
|
type snapshot struct {
|
||||||
|
Address string `json:"address,omitempty"`
|
||||||
|
HeapAlloc uint64 `json:"heapAlloc"`
|
||||||
|
HeapInuse uint64 `json:"heapInuse"`
|
||||||
|
StackInuse uint64 `json:"stackInuse"`
|
||||||
|
TotalAlloc uint64 `json:"totalAlloc"`
|
||||||
|
Mallocs uint64 `json:"mallocs"`
|
||||||
|
NumGC uint32 `json:"numGc"`
|
||||||
|
Goroutines int `json:"goroutines"`
|
||||||
|
Connections int64 `json:"connections"`
|
||||||
|
Accepted int64 `json:"accepted"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type result struct {
|
||||||
|
Adapter string `json:"adapter"`
|
||||||
|
Scenario string `json:"scenario"`
|
||||||
|
Concurrency int `json:"concurrency"`
|
||||||
|
Run int `json:"run"`
|
||||||
|
Requests uint64 `json:"requests,omitempty"`
|
||||||
|
Errors uint64 `json:"errors"`
|
||||||
|
FirstError string `json:"firstError,omitempty"`
|
||||||
|
ElapsedSeconds float64 `json:"elapsedSeconds,omitempty"`
|
||||||
|
RequestsPerSecond float64 `json:"requestsPerSecond,omitempty"`
|
||||||
|
P50Millis float64 `json:"p50Millis,omitempty"`
|
||||||
|
P95Millis float64 `json:"p95Millis,omitempty"`
|
||||||
|
P99Millis float64 `json:"p99Millis,omitempty"`
|
||||||
|
ServerBytesPerRequest float64 `json:"serverBytesPerRequest,omitempty"`
|
||||||
|
ServerAllocsPerRequest float64 `json:"serverAllocsPerRequest,omitempty"`
|
||||||
|
SampledPeakHeapAlloc uint64 `json:"sampledPeakHeapAlloc,omitempty"`
|
||||||
|
IdleHeapDelta int64 `json:"idleHeapDelta,omitempty"`
|
||||||
|
IdleHeapPerConnection float64 `json:"idleHeapPerConnection,omitempty"`
|
||||||
|
Before snapshot `json:"before"`
|
||||||
|
During snapshot `json:"during"`
|
||||||
|
After snapshot `json:"after"`
|
||||||
|
AfterReclaim snapshot `json:"afterReclaim"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type report struct {
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
GoVersion string `json:"goVersion"`
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
GOMAXPROCS int `json:"gomaxprocs"`
|
||||||
|
Duration string `json:"duration"`
|
||||||
|
Notes []string `json:"notes"`
|
||||||
|
Results []result `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type trackedListener struct {
|
||||||
|
net.Listener
|
||||||
|
active, accepted atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type trackedConn struct {
|
||||||
|
net.Conn
|
||||||
|
once sync.Once
|
||||||
|
active *atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *trackedListener) Accept() (net.Conn, error) {
|
||||||
|
c, err := l.Listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
l.active.Add(1)
|
||||||
|
l.accepted.Add(1)
|
||||||
|
return &trackedConn{Conn: c, active: &l.active}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *trackedConn) Close() error {
|
||||||
|
err := c.Conn.Close()
|
||||||
|
c.once.Do(func() { c.active.Add(-1) })
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
serverMode := flag.String("server", "", "internal server subprocess: nethttp or native")
|
||||||
|
duration := flag.Duration("duration", 2*time.Second, "measured duration per traffic case")
|
||||||
|
concurrencyText := flag.String("concurrency", "1,32,128", "traffic concurrency levels")
|
||||||
|
idleText := flag.String("idle", "128,1024", "established idle Keep-Alive connection levels")
|
||||||
|
runs := flag.Int("count", 3, "repetitions; adapter order alternates each repetition")
|
||||||
|
output := flag.String("output", "", "optional JSON report path")
|
||||||
|
flag.Parse()
|
||||||
|
var err error
|
||||||
|
if *serverMode != "" {
|
||||||
|
err = serve(*serverMode)
|
||||||
|
} else {
|
||||||
|
err = compare(*duration, *concurrencyText, *idleText, *runs, *output)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func serve(adapter string) error {
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tracked := &trackedListener{Listener: listener}
|
||||||
|
application := omni.New()
|
||||||
|
application.GET("/health", func(c *omni.Context) error {
|
||||||
|
return c.OK(map[string]string{"status": "ok"})
|
||||||
|
})
|
||||||
|
done := make(chan error, 1)
|
||||||
|
var shutdown func(context.Context) error
|
||||||
|
switch adapter {
|
||||||
|
case "native":
|
||||||
|
server := native.New(application)
|
||||||
|
server.MaxConnections = 8192
|
||||||
|
server.MaxConcurrentRequests = 4096
|
||||||
|
shutdown = server.Shutdown
|
||||||
|
go func() { done <- server.Serve(context.Background(), tracked) }()
|
||||||
|
case "nethttp":
|
||||||
|
server := &http.Server{
|
||||||
|
Handler: application, ReadHeaderTimeout: 5 * time.Second,
|
||||||
|
ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second,
|
||||||
|
IdleTimeout: time.Minute, MaxHeaderBytes: 1 << 20,
|
||||||
|
}
|
||||||
|
shutdown = server.Shutdown
|
||||||
|
go func() {
|
||||||
|
err := server.Serve(tracked)
|
||||||
|
if errors.Is(err, http.ErrServerClosed) {
|
||||||
|
err = nil
|
||||||
|
}
|
||||||
|
done <- err
|
||||||
|
}()
|
||||||
|
default:
|
||||||
|
listener.Close()
|
||||||
|
return fmt.Errorf("unknown server %q", adapter)
|
||||||
|
}
|
||||||
|
stop := func() error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := shutdown(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
return err
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
encoder := json.NewEncoder(os.Stdout)
|
||||||
|
if err := encoder.Encode(snapshot{Address: listener.Addr().String()}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(os.Stdin)
|
||||||
|
for {
|
||||||
|
var command string
|
||||||
|
if err := decoder.Decode(&command); err != nil || command == "stop" {
|
||||||
|
return stop()
|
||||||
|
}
|
||||||
|
if command == "gc" {
|
||||||
|
runtime.GC()
|
||||||
|
}
|
||||||
|
var memory runtime.MemStats
|
||||||
|
runtime.ReadMemStats(&memory)
|
||||||
|
if err := encoder.Encode(snapshot{
|
||||||
|
HeapAlloc: memory.HeapAlloc, HeapInuse: memory.HeapInuse,
|
||||||
|
StackInuse: memory.StackInuse, TotalAlloc: memory.TotalAlloc,
|
||||||
|
Mallocs: memory.Mallocs, NumGC: memory.NumGC,
|
||||||
|
Goroutines: runtime.NumGoroutine(), Connections: tracked.active.Load(), Accepted: tracked.accepted.Load(),
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type child struct {
|
||||||
|
cmd *exec.Cmd
|
||||||
|
input io.WriteCloser
|
||||||
|
encoder *json.Encoder
|
||||||
|
decoder *json.Decoder
|
||||||
|
address string
|
||||||
|
cancel context.CancelFunc
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func startChild(adapter string) (*child, error) {
|
||||||
|
executable, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// This bounds even a broken server or protocol read; it is not a load duration.
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||||
|
c := &child{cmd: exec.CommandContext(ctx, executable, "--server", adapter), cancel: cancel}
|
||||||
|
c.cmd.Stderr = os.Stderr
|
||||||
|
if c.input, err = c.cmd.StdinPipe(); err != nil {
|
||||||
|
cancel()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
output, err := c.cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
c.input.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := c.cmd.Start(); err != nil {
|
||||||
|
cancel()
|
||||||
|
c.input.Close()
|
||||||
|
output.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.encoder, c.decoder = json.NewEncoder(c.input), json.NewDecoder(output)
|
||||||
|
var ready snapshot
|
||||||
|
if err := c.decoder.Decode(&ready); err != nil {
|
||||||
|
cancel()
|
||||||
|
c.cmd.Wait()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.address = ready.Address
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *child) snapshot(gc bool) (snapshot, error) {
|
||||||
|
command := "snapshot"
|
||||||
|
if gc {
|
||||||
|
command = "gc"
|
||||||
|
}
|
||||||
|
var value snapshot
|
||||||
|
if err := c.encoder.Encode(command); err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
err := c.decoder.Decode(&value)
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *child) close() error {
|
||||||
|
if c.closed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c.closed = true
|
||||||
|
defer c.cancel()
|
||||||
|
err := c.encoder.Encode("stop")
|
||||||
|
c.input.Close()
|
||||||
|
return errors.Join(err, c.cmd.Wait())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Histograms use 16 subdivisions per power of two. Percentiles are bucket
|
||||||
|
// upper bounds (at most about 6.25% rounding), with bounded client memory.
|
||||||
|
type histogram [1024]uint64
|
||||||
|
|
||||||
|
func (h *histogram) add(d time.Duration) {
|
||||||
|
n := uint64(max(d.Nanoseconds(), 1))
|
||||||
|
exponent := bits.Len64(n) - 1
|
||||||
|
base := uint64(1) << exponent
|
||||||
|
sub := (n - base) * 16 / base
|
||||||
|
h[exponent*16+int(sub)]++
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *histogram) percentile(count uint64, percent uint64) float64 {
|
||||||
|
target := (count*percent + 99) / 100
|
||||||
|
if target == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var seen uint64
|
||||||
|
for bucket, n := range h {
|
||||||
|
seen += n
|
||||||
|
if seen >= target {
|
||||||
|
base := uint64(1) << (bucket / 16)
|
||||||
|
return float64(base+uint64(bucket%16+1)*base/16) / float64(time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func exchange(client *http.Client, address string) error {
|
||||||
|
req, err := http.NewRequest(http.MethodGet, "http://"+address+"/health", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("X-Request-ID", "perfCheck")
|
||||||
|
response, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body, readErr := io.ReadAll(io.LimitReader(response.Body, 4096))
|
||||||
|
closeErr := response.Body.Close()
|
||||||
|
if response.StatusCode != 200 || response.Header.Get("X-Request-ID") != "perfCheck" || strings.TrimSpace(string(body)) != expectedBody {
|
||||||
|
return fmt.Errorf("invalid response status=%d body=%q", response.StatusCode, body)
|
||||||
|
}
|
||||||
|
return errors.Join(readErr, closeErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func traffic(c *child, concurrency int, duration time.Duration) (result, error) {
|
||||||
|
r := result{Scenario: "traffic", Concurrency: concurrency}
|
||||||
|
client := &http.Client{Transport: &http.Transport{
|
||||||
|
MaxIdleConns: concurrency, MaxIdleConnsPerHost: concurrency,
|
||||||
|
MaxConnsPerHost: concurrency, IdleConnTimeout: time.Minute,
|
||||||
|
}, Timeout: 5 * time.Second}
|
||||||
|
defer client.CloseIdleConnections()
|
||||||
|
// Warm every worker before forcing a baseline GC in the server.
|
||||||
|
warmErrors := make(chan error, concurrency)
|
||||||
|
for i := 0; i < concurrency; i++ {
|
||||||
|
go func() { warmErrors <- exchange(client, c.address) }()
|
||||||
|
}
|
||||||
|
for i := 0; i < concurrency; i++ {
|
||||||
|
if err := <-warmErrors; err != nil {
|
||||||
|
return r, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
if r.Before, err = c.snapshot(true); err != nil {
|
||||||
|
return r, err
|
||||||
|
}
|
||||||
|
type workerResult struct {
|
||||||
|
latencies histogram
|
||||||
|
count, errors uint64
|
||||||
|
firstError string
|
||||||
|
}
|
||||||
|
workers := make([]workerResult, concurrency)
|
||||||
|
start := make(chan struct{})
|
||||||
|
var group sync.WaitGroup
|
||||||
|
var deadline time.Time
|
||||||
|
for i := range workers {
|
||||||
|
group.Add(1)
|
||||||
|
go func(w *workerResult) {
|
||||||
|
defer group.Done()
|
||||||
|
<-start
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
began := time.Now()
|
||||||
|
err := exchange(client, c.address)
|
||||||
|
w.latencies.add(time.Since(began))
|
||||||
|
w.count++
|
||||||
|
if err != nil {
|
||||||
|
w.errors++
|
||||||
|
if w.firstError == "" {
|
||||||
|
w.firstError = err.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(&workers[i])
|
||||||
|
}
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { group.Wait(); close(done) }()
|
||||||
|
begin := time.Now()
|
||||||
|
deadline = begin.Add(duration)
|
||||||
|
close(start)
|
||||||
|
ticker := time.NewTicker(200 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
var sampleErr error
|
||||||
|
loading := true
|
||||||
|
for loading {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
loading = false
|
||||||
|
case <-ticker.C:
|
||||||
|
s, err := c.snapshot(false)
|
||||||
|
if err != nil {
|
||||||
|
sampleErr = err
|
||||||
|
} else if s.HeapAlloc > r.SampledPeakHeapAlloc {
|
||||||
|
r.SampledPeakHeapAlloc = s.HeapAlloc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.ElapsedSeconds = time.Since(begin).Seconds()
|
||||||
|
if sampleErr != nil {
|
||||||
|
return r, sampleErr
|
||||||
|
}
|
||||||
|
var latencies histogram
|
||||||
|
for _, w := range workers {
|
||||||
|
r.Requests += w.count
|
||||||
|
r.Errors += w.errors
|
||||||
|
if r.FirstError == "" {
|
||||||
|
r.FirstError = w.firstError
|
||||||
|
}
|
||||||
|
for i, n := range w.latencies {
|
||||||
|
latencies[i] += n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.During, err = c.snapshot(false); err != nil {
|
||||||
|
return r, err
|
||||||
|
}
|
||||||
|
r.SampledPeakHeapAlloc = max(r.SampledPeakHeapAlloc, r.During.HeapAlloc)
|
||||||
|
if r.Requests == 0 {
|
||||||
|
return r, errors.New("no requests completed")
|
||||||
|
}
|
||||||
|
r.RequestsPerSecond = float64(r.Requests-r.Errors) / r.ElapsedSeconds
|
||||||
|
r.P50Millis = latencies.percentile(r.Requests, 50)
|
||||||
|
r.P95Millis = latencies.percentile(r.Requests, 95)
|
||||||
|
r.P99Millis = latencies.percentile(r.Requests, 99)
|
||||||
|
r.ServerBytesPerRequest = float64(r.During.TotalAlloc-r.Before.TotalAlloc) / float64(r.Requests)
|
||||||
|
r.ServerAllocsPerRequest = float64(r.During.Mallocs-r.Before.Mallocs) / float64(r.Requests)
|
||||||
|
client.CloseIdleConnections()
|
||||||
|
r.After, err = drained(c)
|
||||||
|
if err == nil {
|
||||||
|
r.AfterReclaim, err = c.snapshot(true)
|
||||||
|
}
|
||||||
|
return r, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func idle(c *child, count int) (result, error) {
|
||||||
|
r := result{Scenario: "idle", Concurrency: count}
|
||||||
|
var err error
|
||||||
|
if r.Before, err = c.snapshot(true); err != nil {
|
||||||
|
return r, err
|
||||||
|
}
|
||||||
|
connections := make([]net.Conn, 0, count)
|
||||||
|
defer func() {
|
||||||
|
for _, conn := range connections {
|
||||||
|
conn.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
conn, err := net.DialTimeout("tcp", c.address, 5*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return r, err
|
||||||
|
}
|
||||||
|
connections = append(connections, conn)
|
||||||
|
conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
if _, err := io.WriteString(conn, "GET /health HTTP/1.1\r\nHost: localhost\r\nX-Request-ID: perfCheck\r\n\r\n"); err != nil {
|
||||||
|
return r, err
|
||||||
|
}
|
||||||
|
response, err := http.ReadResponse(bufio.NewReader(conn), &http.Request{Method: http.MethodGet})
|
||||||
|
if err != nil {
|
||||||
|
return r, err
|
||||||
|
}
|
||||||
|
body, readErr := io.ReadAll(response.Body)
|
||||||
|
response.Body.Close()
|
||||||
|
if readErr != nil || response.StatusCode != 200 || strings.TrimSpace(string(body)) != expectedBody || response.Close {
|
||||||
|
return r, fmt.Errorf("invalid Keep-Alive response: status=%d error=%v", response.StatusCode, readErr)
|
||||||
|
}
|
||||||
|
conn.SetDeadline(time.Time{})
|
||||||
|
}
|
||||||
|
if r.During, err = c.snapshot(true); err != nil {
|
||||||
|
return r, err
|
||||||
|
}
|
||||||
|
if r.During.Connections != int64(count) {
|
||||||
|
return r, fmt.Errorf("idle connections: want %d, have %d", count, r.During.Connections)
|
||||||
|
}
|
||||||
|
r.IdleHeapDelta = int64(r.During.HeapAlloc) - int64(r.Before.HeapAlloc)
|
||||||
|
r.IdleHeapPerConnection = float64(r.IdleHeapDelta) / float64(count)
|
||||||
|
for _, conn := range connections {
|
||||||
|
conn.Close()
|
||||||
|
}
|
||||||
|
connections = nil
|
||||||
|
r.After, err = drained(c)
|
||||||
|
if err == nil {
|
||||||
|
r.AfterReclaim, err = c.snapshot(true)
|
||||||
|
}
|
||||||
|
return r, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func drained(c *child) (snapshot, error) {
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for {
|
||||||
|
s, err := c.snapshot(false)
|
||||||
|
if err != nil {
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
if s.Connections == 0 {
|
||||||
|
return c.snapshot(true)
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return s, fmt.Errorf("%d connections remained after clients closed", s.Connections)
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func levels(text string) ([]int, error) {
|
||||||
|
var values []int
|
||||||
|
if text == "" {
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
for _, item := range strings.Split(text, ",") {
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(item))
|
||||||
|
if err != nil || n < 1 || n > 4096 {
|
||||||
|
return nil, fmt.Errorf("invalid level %q (expected 1..4096)", item)
|
||||||
|
}
|
||||||
|
values = append(values, n)
|
||||||
|
}
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func compare(duration time.Duration, concurrencyText, idleText string, count int, output string) error {
|
||||||
|
concurrency, err := levels(concurrencyText)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
idleLevels, err := levels(idleText)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count < 1 || duration < 200*time.Millisecond || duration > 30*time.Second || len(concurrency)+len(idleLevels) == 0 {
|
||||||
|
return errors.New("count must be positive, duration 200ms..30s, and at least one scenario selected")
|
||||||
|
}
|
||||||
|
report := report{
|
||||||
|
CreatedAt: time.Now().UTC().Format(time.RFC3339), GoVersion: runtime.Version(),
|
||||||
|
Platform: runtime.GOOS + "/" + runtime.GOARCH, GOMAXPROCS: runtime.GOMAXPROCS(0), Duration: duration.String(),
|
||||||
|
Notes: []string{
|
||||||
|
"Loopback HTTP/1.1 health response, fixed requestId, no TLS, access logs or database.",
|
||||||
|
"Each case starts a fresh server process; load client runs in the parent process on the same machine.",
|
||||||
|
"Closed-loop concurrency; throughput is successful responses / actual elapsed time, including drain.",
|
||||||
|
"Latency percentiles include failures and are histogram upper bounds with <=6.25% rounding.",
|
||||||
|
"Memory is server-only runtime.MemStats (not RSS or kernel socket memory); sampling interval 200ms.",
|
||||||
|
"Idle memory measured after a forced GC; delta includes heap growth amortized across connections, excluding stacks.",
|
||||||
|
"After is after closing clients and one forced GC; afterReclaim is a second GC to observe pooled-cache release.",
|
||||||
|
"Native limits: 8192 connections / 4096 active requests. These cases do not benchmark overload rejection.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
var caseErrors []error
|
||||||
|
for run := 1; run <= count; run++ {
|
||||||
|
adapters := []string{"nethttp", "native"}
|
||||||
|
if run%2 == 0 {
|
||||||
|
adapters[0], adapters[1] = adapters[1], adapters[0]
|
||||||
|
}
|
||||||
|
for _, scenario := range []struct {
|
||||||
|
name string
|
||||||
|
levels []int
|
||||||
|
}{{"traffic", concurrency}, {"idle", idleLevels}} {
|
||||||
|
for _, level := range scenario.levels {
|
||||||
|
for _, adapter := range adapters {
|
||||||
|
c, err := startChild(adapter)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var value result
|
||||||
|
if scenario.name == "traffic" {
|
||||||
|
value, err = traffic(c, level, duration)
|
||||||
|
} else {
|
||||||
|
value, err = idle(c, level)
|
||||||
|
}
|
||||||
|
err = errors.Join(err, c.close())
|
||||||
|
value.Adapter, value.Run = adapter, run
|
||||||
|
if err != nil {
|
||||||
|
value.Errors++
|
||||||
|
value.FirstError = err.Error()
|
||||||
|
}
|
||||||
|
report.Results = append(report.Results, value)
|
||||||
|
if value.Errors > 0 {
|
||||||
|
caseErrors = append(caseErrors, fmt.Errorf("%s %s/%d run %d: %s", adapter, scenario.name, level, run, value.FirstError))
|
||||||
|
}
|
||||||
|
if scenario.name == "traffic" {
|
||||||
|
fmt.Printf("%s concurrency=%d run=%d: %.0f req/s p95=%.3fms p99=%.3fms server=%.0f B/req errors=%d\n", adapter, level, run, value.RequestsPerSecond, value.P95Millis, value.P99Millis, value.ServerBytesPerRequest, value.Errors)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("%s idle=%d run=%d: server heap delta=%.2f MiB (%.0f B/conn), goroutines=%d->%d->%d errors=%d\n", adapter, level, run, float64(value.IdleHeapDelta)/(1<<20), value.IdleHeapPerConnection, value.Before.Goroutines, value.During.Goroutines, value.After.Goroutines, value.Errors)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if output != "" {
|
||||||
|
data, err := json.MarshalIndent(report, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(output, append(data, '\n'), 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("Report: %s\n", output)
|
||||||
|
}
|
||||||
|
return errors.Join(caseErrors...)
|
||||||
|
}
|
||||||
63
internal/perfcheck/main_test.go
Normal file
63
internal/perfcheck/main_test.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A load generator must never report a fast failure page as good throughput.
|
||||||
|
func TestExchangeRejectsInvalidResponses(t *testing.T) {
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
status int
|
||||||
|
body string
|
||||||
|
requestID string
|
||||||
|
truncated bool
|
||||||
|
wantOK bool
|
||||||
|
}{
|
||||||
|
{"valid", 200, expectedBody, "perfCheck", false, true},
|
||||||
|
{"overload", 503, "server busy", "perfCheck", false, false},
|
||||||
|
{"wrongPayload", 200, `{"data":"wrong"}`, "perfCheck", false, false},
|
||||||
|
{"missingRequestId", 200, expectedBody, "", false, false},
|
||||||
|
{"truncated", 200, expectedBody, "perfCheck", true, false},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get("X-Request-ID") != "perfCheck" {
|
||||||
|
t.Error("client did not send fixed requestId")
|
||||||
|
}
|
||||||
|
w.Header().Set("X-Request-ID", tt.requestID)
|
||||||
|
if tt.truncated {
|
||||||
|
w.Header().Set("Content-Length", "4096")
|
||||||
|
}
|
||||||
|
w.WriteHeader(tt.status)
|
||||||
|
_, _ = w.Write([]byte(tt.body))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
err := exchange(server.Client(), strings.TrimPrefix(server.URL, "http://"))
|
||||||
|
if (err == nil) != tt.wantOK {
|
||||||
|
t.Fatalf("valid=%v, error=%v", tt.wantOK, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPercentileBounds(t *testing.T) {
|
||||||
|
var h histogram
|
||||||
|
// Known ranks, including a tail much slower than the median.
|
||||||
|
for i := 1; i <= 100; i++ {
|
||||||
|
h.add(time.Duration(i) * time.Millisecond)
|
||||||
|
}
|
||||||
|
for _, p := range []uint64{50, 95, 99} {
|
||||||
|
value := h.percentile(100, p)
|
||||||
|
if value < float64(p) || value > float64(p)*1.0625 {
|
||||||
|
t.Fatalf("p%d=%gms outside advertised bucket rounding", p, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if value := h.percentile(0, 95); value != 0 {
|
||||||
|
t.Fatalf("empty histogram returned %g", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
1735
performance/tcpBaseline.json
Normal file
1735
performance/tcpBaseline.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,12 @@ def main():
|
|||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument("--go", default="go")
|
parser.add_argument("--go", default="go")
|
||||||
parser.add_argument("--count", type=int, default=3)
|
parser.add_argument("--count", type=int, default=3)
|
||||||
|
parser.add_argument("--load", action="store_true", help="Also compare isolated servers and idle connection memory")
|
||||||
|
parser.add_argument("--duration", default="2s", help="Duration per load scenario")
|
||||||
|
parser.add_argument("--output", help="JSON output path for --load")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
if args.count < 1:
|
||||||
|
parser.error("--count must be positive")
|
||||||
root = Path(__file__).resolve().parent.parent
|
root = Path(__file__).resolve().parent.parent
|
||||||
env = dict(os.environ, GOWORK="off")
|
env = dict(os.environ, GOWORK="off")
|
||||||
checks = [
|
checks = [
|
||||||
@@ -23,12 +28,13 @@ def main():
|
|||||||
"-benchmem", "-count", str(args.count), "./transport"],
|
"-benchmem", "-count", str(args.count), "./transport"],
|
||||||
"transport benchmarks",
|
"transport benchmarks",
|
||||||
),
|
),
|
||||||
(
|
|
||||||
[args.go, "test", "-run", "^$", "-bench", "^BenchmarkNative",
|
|
||||||
"-benchmem", "-count", str(args.count), "./transport/native"],
|
|
||||||
"native TCP benchmarks",
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
if args.load:
|
||||||
|
command = [args.go, "run", "./internal/perfcheck", "--duration", args.duration,
|
||||||
|
"--count", str(args.count)]
|
||||||
|
if args.output:
|
||||||
|
command += ["--output", str(Path(args.output).resolve())]
|
||||||
|
checks.append((command, "isolated server concurrency and idle-memory matrix"))
|
||||||
for command, name in checks:
|
for command, name in checks:
|
||||||
print(f"Running {name}", flush=True)
|
print(f"Running {name}", flush=True)
|
||||||
result = subprocess.run(command, cwd=root, env=env, text=True,
|
result = subprocess.run(command, cwd=root, env=env, text=True,
|
||||||
|
|||||||
125
transport/benchmarkTCP_test.go
Normal file
125
transport/benchmarkTCP_test.go
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
package transport_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/transport/native"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Both servers use the same application, client, request validation and fixed
|
||||||
|
// concurrency. B/op includes client AND server allocations in this process;
|
||||||
|
// ns/op is aggregate throughput cost, not individual request latency.
|
||||||
|
func BenchmarkTransportTCPApplicationRequest(b *testing.B) {
|
||||||
|
for _, adapter := range []string{"nethttp", "native"} {
|
||||||
|
for _, concurrency := range []int{1, 32, 128} {
|
||||||
|
b.Run(fmt.Sprintf("%s/concurrency%d", adapter, concurrency), func(b *testing.B) {
|
||||||
|
benchmarkTCP(b, adapter, concurrency)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func benchmarkTCP(b *testing.B, adapter string, concurrency int) {
|
||||||
|
application := omni.New()
|
||||||
|
application.GET("/health", func(ctx *omni.Context) error {
|
||||||
|
return ctx.OK(map[string]string{"status": "ok"})
|
||||||
|
})
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
done := make(chan error, 1)
|
||||||
|
var shutdown func(context.Context) error
|
||||||
|
if adapter == "native" {
|
||||||
|
server := native.New(application)
|
||||||
|
server.MaxConnections = 1024
|
||||||
|
server.MaxConcurrentRequests = 1024
|
||||||
|
shutdown = server.Shutdown
|
||||||
|
go func() { done <- server.Serve(context.Background(), listener) }()
|
||||||
|
} else {
|
||||||
|
server := &http.Server{
|
||||||
|
Handler: application, ReadHeaderTimeout: 5 * time.Second,
|
||||||
|
ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second,
|
||||||
|
IdleTimeout: time.Minute, MaxHeaderBytes: 1 << 20,
|
||||||
|
}
|
||||||
|
shutdown = server.Shutdown
|
||||||
|
go func() {
|
||||||
|
err := server.Serve(listener)
|
||||||
|
if err == http.ErrServerClosed {
|
||||||
|
err = nil
|
||||||
|
}
|
||||||
|
done <- err
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
client := &http.Client{Transport: &http.Transport{
|
||||||
|
MaxIdleConns: 1024,
|
||||||
|
MaxIdleConnsPerHost: 1024,
|
||||||
|
MaxConnsPerHost: concurrency,
|
||||||
|
IdleConnTimeout: time.Minute,
|
||||||
|
}, Timeout: 5 * time.Second}
|
||||||
|
b.Cleanup(func() {
|
||||||
|
client.CloseIdleConnections()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := shutdown(ctx); err != nil {
|
||||||
|
b.Error(err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
b.Error(err)
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
b.Error("server did not stop")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
endpoint := "http://" + listener.Addr().String() + "/health"
|
||||||
|
request := func() error {
|
||||||
|
response, err := client.Get(endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, readErr := io.Copy(io.Discard, response.Body)
|
||||||
|
closeErr := response.Body.Close()
|
||||||
|
if response.StatusCode != http.StatusOK || response.Header.Get("X-Request-ID") == "" {
|
||||||
|
return fmt.Errorf("unexpected response: status=%d", response.StatusCode)
|
||||||
|
}
|
||||||
|
if readErr != nil {
|
||||||
|
return readErr
|
||||||
|
}
|
||||||
|
return closeErr
|
||||||
|
}
|
||||||
|
if err := request(); err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
errors := make(chan error, concurrency)
|
||||||
|
var workers sync.WaitGroup
|
||||||
|
b.ReportAllocs()
|
||||||
|
b.ResetTimer()
|
||||||
|
for worker := 0; worker < concurrency; worker++ {
|
||||||
|
workers.Add(1)
|
||||||
|
go func(worker int) {
|
||||||
|
defer workers.Done()
|
||||||
|
for index := worker; index < b.N; index += concurrency {
|
||||||
|
if err := request(); err != nil {
|
||||||
|
errors <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(worker)
|
||||||
|
}
|
||||||
|
workers.Wait()
|
||||||
|
b.StopTimer()
|
||||||
|
close(errors)
|
||||||
|
for err := range errors {
|
||||||
|
b.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
package native_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
|
||||||
"git.yuntuo.hk/k2safe/go-omni/transport/native"
|
|
||||||
netrouter "git.yuntuo.hk/k2safe/go-omni/transport/nethttp"
|
|
||||||
)
|
|
||||||
|
|
||||||
// BenchmarkNativeApplicationRequest measures the real TCP path with a reused
|
|
||||||
// client connection. It is intentionally separate from the in-process router
|
|
||||||
// benchmarks in transport/benchmark_test.go.
|
|
||||||
func BenchmarkNativeApplicationRequest(b *testing.B) {
|
|
||||||
application := omni.NewWithRouter(netrouter.New())
|
|
||||||
application.GET("/health", func(ctx *omni.Context) error {
|
|
||||||
return ctx.OK(map[string]string{"status": "ok"})
|
|
||||||
})
|
|
||||||
server := native.New(application)
|
|
||||||
server.MaxConnections = 1024
|
|
||||||
server.MaxConcurrentRequests = 1024
|
|
||||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
||||||
if err != nil {
|
|
||||||
b.Fatal(err)
|
|
||||||
}
|
|
||||||
done := make(chan error, 1)
|
|
||||||
go func() { done <- server.Serve(context.Background(), listener) }()
|
|
||||||
waitForBenchmarkServer(b, listener.Addr().String())
|
|
||||||
|
|
||||||
client := &http.Client{Transport: &http.Transport{
|
|
||||||
MaxIdleConns: 1024,
|
|
||||||
MaxIdleConnsPerHost: 1024,
|
|
||||||
IdleConnTimeout: time.Minute,
|
|
||||||
}}
|
|
||||||
endpoint := "http://" + listener.Addr().String() + "/health"
|
|
||||||
response, err := client.Get(endpoint)
|
|
||||||
if err != nil {
|
|
||||||
b.Fatal(err)
|
|
||||||
}
|
|
||||||
_, _ = io.Copy(io.Discard, response.Body)
|
|
||||||
response.Body.Close()
|
|
||||||
|
|
||||||
var failed atomic.Bool
|
|
||||||
var failure atomic.Value
|
|
||||||
b.ReportAllocs()
|
|
||||||
b.ResetTimer()
|
|
||||||
b.RunParallel(func(pb *testing.PB) {
|
|
||||||
for pb.Next() {
|
|
||||||
response, err := client.Get(endpoint)
|
|
||||||
if err != nil {
|
|
||||||
if failed.CompareAndSwap(false, true) {
|
|
||||||
failure.Store(err.Error())
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
_, _ = io.Copy(io.Discard, response.Body)
|
|
||||||
response.Body.Close()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
b.StopTimer()
|
|
||||||
client.CloseIdleConnections()
|
|
||||||
shutdownContext, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if err := server.Shutdown(shutdownContext); err != nil {
|
|
||||||
b.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := <-done; err != nil {
|
|
||||||
b.Fatal(err)
|
|
||||||
}
|
|
||||||
if failed.Load() {
|
|
||||||
b.Fatalf("native request failed: %s", failure.Load().(string))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func waitForBenchmarkServer(b *testing.B, address string) {
|
|
||||||
b.Helper()
|
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
|
||||||
for {
|
|
||||||
conn, err := net.DialTimeout("tcp", address, 50*time.Millisecond)
|
|
||||||
if err == nil {
|
|
||||||
conn.Close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
b.Fatalf("server did not start: %v", err)
|
|
||||||
}
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user