Files
Chris LuandGitHub a88acaf061 Add performance CI (profiling, throughput, S3 read/write) (#10105)
* test: add self-contained S3 read/write load tool

Concurrent PUT/GET against the S3 gateway, reporting requests/sec,
transfer rate, and latency percentiles. Built on the aws-sdk-go-v2
client the S3 tests already use, so no extra benchmark binary is needed.

* ci: add performance workflow

Three parallel jobs: cpu/heap pprof of the server under write load,
native throughput via weed benchmark plus the Go micro-benchmarks, and
an S3 read/write benchmark against the gateway. Runs on push to master
and manual dispatch with tunable duration, object count, size, and
concurrency.
2026-06-24 22:44:03 -07:00

224 lines
5.9 KiB
Go

// Command s3_benchmark drives concurrent PUT and GET load against an S3
// gateway and reports throughput and latency. It is a self-contained load
// generator for the performance CI, using the aws-sdk-go-v2 client the rest
// of the S3 tests already depend on.
package main
import (
"bytes"
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"slices"
"sync"
"sync/atomic"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
)
func main() {
endpoint := flag.String("endpoint", "http://localhost:8000", "S3 gateway endpoint")
accessKey := flag.String("access-key", "some_access_key1", "S3 access key")
secretKey := flag.String("secret-key", "some_secret_key1", "S3 secret key")
region := flag.String("region", "us-east-1", "S3 region")
bucket := flag.String("bucket", "perf-benchmark", "bucket to write into")
objects := flag.Int("objects", 10000, "number of objects")
size := flag.Int("size", 1024, "object size in bytes")
concurrency := flag.Int("concurrency", 16, "concurrent workers")
mode := flag.String("mode", "both", "write, read, or both")
flag.Parse()
cfg, err := config.LoadDefaultConfig(context.Background(),
config.WithRegion(*region),
config.WithRetryMaxAttempts(1),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(*accessKey, *secretKey, "")),
)
if err != nil {
fatalf("load aws config: %v", err)
}
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String(*endpoint)
o.UsePathStyle = true
})
if err := ensureBucket(client, *bucket); err != nil {
fatalf("ensure bucket: %v", err)
}
payload := make([]byte, *size)
for i := range payload {
payload[i] = byte(i)
}
doWrite := *mode == "both" || *mode == "write"
doRead := *mode == "both" || *mode == "read"
if doWrite {
report("S3 WRITE", *concurrency, *size, run(*concurrency, *objects, func(key string) error {
_, err := client.PutObject(context.Background(), &s3.PutObjectInput{
Bucket: bucket,
Key: aws.String(key),
Body: bytes.NewReader(payload),
ContentLength: aws.Int64(int64(*size)),
})
return err
}))
}
if doRead {
report("S3 READ", *concurrency, *size, run(*concurrency, *objects, func(key string) error {
out, err := client.GetObject(context.Background(), &s3.GetObjectInput{
Bucket: bucket,
Key: aws.String(key),
})
if err != nil {
return err
}
defer out.Body.Close()
_, err = io.Copy(io.Discard, out.Body)
return err
}))
}
}
type result struct {
completed int64
failed int64
elapsed time.Duration
latencies []time.Duration
firstErr error
}
// run fans `total` keyed operations across `workers` goroutines, timing each.
func run(workers, total int, op func(key string) error) result {
var (
completed, failed, nextKey atomic.Int64
mu sync.Mutex
firstErr error
latencies = make([]time.Duration, 0, total)
wg sync.WaitGroup
)
start := time.Now()
for w := 0; w < workers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
local := make([]time.Duration, 0, total/workers+1)
for {
i := nextKey.Add(1) - 1
if i >= int64(total) {
break
}
key := fmt.Sprintf("obj-%d", i)
opStart := time.Now()
err := op(key)
local = append(local, time.Since(opStart))
if err != nil {
failed.Add(1)
mu.Lock()
if firstErr == nil {
firstErr = err
}
mu.Unlock()
continue
}
completed.Add(1)
}
mu.Lock()
latencies = append(latencies, local...)
mu.Unlock()
}()
}
wg.Wait()
return result{
completed: completed.Load(),
failed: failed.Load(),
elapsed: time.Since(start),
latencies: latencies,
firstErr: firstErr,
}
}
func report(label string, concurrency, size int, r result) {
secs := r.elapsed.Seconds()
transferred := r.completed * int64(size)
rps := 0.0
kbps := 0.0
if secs > 0 {
rps = float64(r.completed) / secs
kbps = float64(transferred) / 1024 / secs
}
fmt.Printf("\n%s results:\n", label)
fmt.Printf("Concurrency Level: %d\n", concurrency)
fmt.Printf("Time taken for tests: %.3f seconds\n", secs)
fmt.Printf("Completed requests: %d\n", r.completed)
fmt.Printf("Failed requests: %d\n", r.failed)
fmt.Printf("Total transferred: %d bytes\n", transferred)
fmt.Printf("Requests per second: %.2f [#/sec]\n", rps)
fmt.Printf("Transfer rate: %.2f [Kbytes/sec]\n", kbps)
p := percentiles(r.latencies)
fmt.Printf("Latency (ms): avg=%.2f p50=%.2f p90=%.2f p99=%.2f max=%.2f\n",
ms(p.avg), ms(p.p50), ms(p.p90), ms(p.p99), ms(p.max))
if r.failed > 0 && r.firstErr != nil {
fmt.Printf("First error: %v\n", r.firstErr)
os.Exit(1)
}
}
type latencyStats struct {
avg, p50, p90, p99, max time.Duration
}
func percentiles(d []time.Duration) latencyStats {
if len(d) == 0 {
return latencyStats{}
}
slices.Sort(d)
var sum time.Duration
for _, v := range d {
sum += v
}
at := func(q float64) time.Duration {
idx := int(q * float64(len(d)))
if idx >= len(d) {
idx = len(d) - 1
}
return d[idx]
}
return latencyStats{
avg: sum / time.Duration(len(d)),
p50: at(0.50),
p90: at(0.90),
p99: at(0.99),
max: d[len(d)-1],
}
}
func ms(d time.Duration) float64 { return float64(d) / float64(time.Millisecond) }
func ensureBucket(client *s3.Client, bucket string) error {
_, err := client.CreateBucket(context.Background(), &s3.CreateBucketInput{Bucket: aws.String(bucket)})
if err == nil {
return nil
}
var owned *types.BucketAlreadyOwnedByYou
var exists *types.BucketAlreadyExists
if errors.As(err, &owned) || errors.As(err, &exists) {
return nil
}
return err
}
func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
os.Exit(1)
}