1
0
mirror of https://github.com/ossrs/srs.git synced 2026-10-25 21:59:20 +03:00
Files
srs/cmd/perf/workers.go
Winlin 7b737e64df Perf: Load and profile tools, and the first SRS and Go proxy fixes. v8.0.63 (#4785)
SRS gets performance tools that load and profile SRS in Docker for every
protocol: a lean load tool, an internal skill that runs an improvement
round, and a measurement method that holds up to a 10% verdict. The same
tools then measure the Go proxy. The first runs found one SRS fix and
three Go proxy fixes.

The load tool, a standalone Go module in cmd/perf (bin/srs-perf):

- Per-core worker processes shard the clients and report counters each
second. Lean clients for every protocol: RTMP publish and play,
HTTP-FLV, HTTP-TS, HLS, DASH, RTSP, SRT, WHIP and WHEP. No decode;
frames and lag come from timestamps.
- 17 scenarios: each protocol's fan-out, publishers (rtmp-pub,
whip-pub), many-streams, cross-protocol bridges (srt-rtmp, srt-whep,
whip-rtmp, whip-flv), and delay probes for the RTMP pause, forward and
edge.
- run ramps by doubling to a capacity, then holds at 50% and 90% of it.
compare checks that the setups match and reports capacity and CPU per
client at the 10% line.
- make perf-image builds an optimized SRS with symbols and frame
pointers, with perf, FlameGraph and FFmpeg. SRS runs on one pinned core
and is configured only by env files, one per scenario.

The measurement method:

- A flat CBR source, checked to ±3% per second, since SRS CPU follows
the bitrate.
- SRS CPU from its cgroup once a second. Holds have no profiler in the
measuring window; each count gets its own separate profile hold.
- perf-ab.sh builds two revisions and runs them side by side on separate
cpusets. compare gives the 95% CI of the paired per-second difference.
- perf-verify.sh checks every run: capacity, healthy steps, profiles and
symbols, noise, and what bound the run.

The internal-perf-for-srs skill:

- SKILL.md holds the rules, the shared tools and the round workflow. A
Target Router loads references/cpp-server.md for the C++ media server or
references/go-server.md for the Go proxy.
- Each reference covers its tools, topology, scenarios, measuring, how
to read its profiles (Linux perf, or Go pprof and trace), A/B, the
verified scenarios with their capacity and top bottleneck, and how to
start a round.
- srs-develop routes performance, benchmark, load test and profile tasks
to it. perf-skill-test.sh checks the text against the scenario registry.
- Reports are git-ignored, since their numbers hold only for the machine
that made them.
- The skills now name each server by its job: the C++ media server and
the Go proxy, with no "generation".

C++ media server, one core, Linux arm64 in Docker:

- Fix: an empty SrsSharedPtr holds no reference count, so each fan-out
copy of a message does one malloc instead of three. Covered by
srs_utest_ai05.cpp.
- Capacity: rtmp-rtmp, rtmp-flv, rtmp-hls, whip-rtmp and whip-flv
12,800; rtmp-dash and srt-rtmp 6,400; rtmp-ts and rtmp-rtsp 3,200;
whip-whep 2,000 (cap); rtmp-whep, srt-whep, srt-srt and rtmp-pub 1,280;
whip-pub 1,000 (cap); many-streams 640 streams.
- Top bottlenecks found, not fixed: the RTMP fan-out copy,
_st_epoll_dispatch with EAGAIN reads, 4 KB reads and writes for HLS and
DASH segments, one write per message for HTTP-TS and two per packet for
RTSP, the SRTP replay window in libsrtp, and libsrt for SRT.
- Delays: the paused RTMP player's 500 ms sleep, and the forward's and
edge publish's 500 ms receive timeout, each adding about 220 ms of lag.

The Go proxy, cmd/proxy, on one core in front of 1 to 4 SRS origins:

- perf-up.sh -target proxy, run -target proxy, which samples the proxy
and each origin, Go profiles in each profile hold (CPU, allocations,
mutex, block, trace, GC), and proxy A/B.
- Fix: HTTP-FLV, HTTP-TS and HLS relay the origin's error status,
headers and body, rather than a 500 with the origin's address.
- Fix: a WebRTC STUN binding routes by its ufrag, not a cached client
address. WHIP and WHEP failed from 160 clients on three origins.
- Capacity on three origins: rtmp-flv and rtmp-hls 12,800; rtmp-rtmp and
rtmp-ts 6,400; whip-whep 2,000 (cap); srt-srt and rtmp-pub 1,280;
whip-pub 1,000 (cap); many-streams 1,280 streams.

---------

Co-authored-by: chatgpt-codex-connector[bot] <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:46:30 -04:00

527 lines
14 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"os"
"os/exec"
"strconv"
"sync"
"sync/atomic"
"time"
)
type workerRequest struct {
Kind string `json:"kind"`
Scenario string `json:"scenario,omitempty"`
Clients int `json:"clients,omitempty"`
// Media is the source the publisher loops; players measure lag by it.
Media string `json:"media,omitempty"`
// Streams are the streams players split over, or one, the prefix of the
// publishers' streams; First is the index in the step of the worker's first
// client.
Streams []string `json:"streams,omitempty"`
First int `json:"first,omitempty"`
Second int `json:"second,omitempty"`
// LatestTimestamps is each stream's publisher's newest timestamp.
LatestTimestamps []uint32 `json:"latest_timestamps,omitempty"`
// Final marks the step's last sample: a player with no frame yet fails.
Final bool `json:"final,omitempty"`
}
type workerResponse struct {
Frames int64 `json:"frames"`
Segments int64 `json:"segments,omitempty"`
Bytes int64 `json:"bytes"`
LagMillis int64 `json:"lag_millis"`
Failures int64 `json:"failures"`
}
type workerClient struct {
cmd *exec.Cmd
input io.WriteCloser
enc *json.Encoder
dec *json.Decoder
}
type workerGroup struct {
workers []*workerClient
}
func splitClients(clients, cores int) []int {
if clients < 0 || cores <= 0 {
return nil
}
shares := make([]int, cores)
for i := range shares {
shares[i] = clients / cores
if i < clients%cores {
shares[i]++
}
}
return shares
}
func startWorkers(ctx context.Context, factory func(int) *exec.Cmd, scenario, media string, streams []string, clients, cores int) (*workerGroup, error) {
shares := splitClients(clients, cores)
if shares == nil {
return nil, fmt.Errorf("clients must be nonnegative and cores positive")
}
offsets := clientOffsets(shares)
if _, ok := scenarioByName(scenario); !ok {
return nil, fmt.Errorf("unknown scenario %q", scenario)
}
if factory == nil {
factory = func(id int) *exec.Cmd {
return exec.CommandContext(ctx, os.Args[0], "worker", "-id", strconv.Itoa(id))
}
}
group := &workerGroup{}
for id, share := range shares {
if err := ctx.Err(); err != nil {
group.close()
return nil, err
}
cmd := factory(id)
input, err := cmd.StdinPipe()
if err != nil {
group.close()
return nil, err
}
output, err := cmd.StdoutPipe()
if err != nil {
input.Close()
group.close()
return nil, err
}
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
input.Close()
group.close()
return nil, fmt.Errorf("start worker %d: %w", id, err)
}
worker := &workerClient{cmd: cmd, input: input, enc: json.NewEncoder(input), dec: json.NewDecoder(output)}
group.workers = append(group.workers, worker)
if err := worker.enc.Encode(workerRequest{Kind: "start", Scenario: scenario, Media: media, Streams: streams, First: offsets[id], Clients: share}); err != nil {
group.close()
return nil, fmt.Errorf("configure worker %d: %w", id, err)
}
var ack workerResponse
if err := worker.dec.Decode(&ack); err != nil {
group.close()
return nil, fmt.Errorf("ack worker %d: %w", id, err)
}
}
return group, nil
}
func (g *workerGroup) pids() []int {
pids := make([]int, len(g.workers))
for i, worker := range g.workers {
pids[i] = worker.cmd.Process.Pid
}
return pids
}
func (g *workerGroup) collect(second int) (workerResponse, error) {
return g.collectWithTimestamp(second, nil, false)
}
func (g *workerGroup) collectWithTimestamp(second int, timestamps []uint32, final bool) (workerResponse, error) {
if second <= 0 {
return workerResponse{}, fmt.Errorf("sample second must be positive")
}
for i, worker := range g.workers {
if err := worker.enc.Encode(workerRequest{Kind: "sample", Second: second, LatestTimestamps: timestamps, Final: final}); err != nil {
return workerResponse{}, fmt.Errorf("request worker %d: %w", i, err)
}
}
var total workerResponse
for i, worker := range g.workers {
var response workerResponse
if err := worker.dec.Decode(&response); err != nil {
return workerResponse{}, fmt.Errorf("read worker %d: %w", i, err)
}
total.Frames += response.Frames
total.Segments += response.Segments
total.Bytes += response.Bytes
total.Failures += response.Failures
if response.LagMillis > total.LagMillis {
total.LagMillis = response.LagMillis
}
}
return total, nil
}
func (g *workerGroup) collectEverySecond(ctx context.Context, output chan<- workerResponse) error {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
second := 0
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
second++
response, err := g.collect(second)
if err != nil {
return err
}
select {
case output <- response:
case <-ctx.Done():
return ctx.Err()
}
}
}
}
func (g *workerGroup) close() error {
var first error
for _, worker := range g.workers {
if err := worker.enc.Encode(workerRequest{Kind: "stop"}); err != nil && first == nil {
first = err
}
worker.input.Close()
}
for _, worker := range g.workers {
if err := worker.cmd.Wait(); err != nil && first == nil {
first = err
}
}
g.workers = nil
return first
}
// classifyWorkerLoad marks a failed step load-bound when, in one second, the
// workers average above 95% CPU: the load cpuset is pegged. A worker is a Go
// process that runs on every core of the cpuset, so one worker above 100% alone
// is not a pegged load.
func classifyWorkerLoad(failed bool, cpus []float64) validity {
if !failed || len(cpus) == 0 {
return validity{Valid: true}
}
var total float64
for _, cpu := range cpus {
total += cpu
}
if total/float64(len(cpus)) > 95 {
return validity{Valid: false, Reason: "load-bound"}
}
return validity{Valid: true}
}
func serveWorker(input io.Reader, output io.Writer) error {
dec := json.NewDecoder(input)
enc := json.NewEncoder(output)
var start workerRequest
if err := dec.Decode(&start); err != nil {
return err
}
if start.Kind != "start" || start.Clients < 0 {
return fmt.Errorf("invalid worker start request")
}
s, ok := scenarioByName(start.Scenario)
if !ok {
return fmt.Errorf("unknown scenario %q", start.Scenario)
}
var tags []rtmpMessage
var timeline *sourceTimeline
if start.Media != "" {
var err error
if tags, err = loadFLV(start.Media); err != nil {
return err
}
if timeline, err = lagTimeline(start.Media, tags); err != nil {
return err
}
}
streams := start.Streams
if len(streams) == 0 {
streams = []string{"perf-run"}
}
runtime, err := startClients(s, start.Clients, streams, start.First, start.Media, tags, timeline)
if err != nil {
return err
}
defer runtime.close()
if err := enc.Encode(workerResponse{}); err != nil {
return err
}
for {
var request workerRequest
if err := dec.Decode(&request); err != nil {
return err
}
switch request.Kind {
case "sample":
if request.Second <= 0 {
return fmt.Errorf("sample second must be positive")
}
if err := enc.Encode(runtime.sample(request.LatestTimestamps, request.Final)); err != nil {
return err
}
case "stop":
return nil
default:
return fmt.Errorf("unknown worker request %q", request.Kind)
}
}
}
type workerPlayers struct {
stop chan struct{}
started time.Time
// gap is how long a player may get nothing before it fails, its
// scenario's gap.
gap time.Duration
wg sync.WaitGroup
frames atomic.Int64
segments atomic.Int64
bytes atomic.Int64
failures atomic.Int64
last []atomic.Int64
latest []atomic.Uint32
timeline *sourceTimeline
// anchor is 1 + the time in the source loop of a player's first video frame
// with a unique payload, 0 until it has one; anchorTS is that frame's
// timestamp as the player got it.
anchor []atomic.Int64
anchorTS []atomic.Uint32
// offset turns a player's timestamps into publisher time; only sample uses
// it, once the player has an anchor.
offset []int64
resolved []bool
// clocks is each player's own publisher, in a many-streams step; nil
// when the players play the publishers whose times sample gets.
clocks []*mediaClock
// streams is the index of each player's stream in sample's timestamps;
// nil when every player plays the first.
streams []int
}
func newWorkerPlayers(clients int, timeline *sourceTimeline) *workerPlayers {
return &workerPlayers{
stop: make(chan struct{}), started: time.Now(), gap: 5 * time.Second, timeline: timeline,
last: make([]atomic.Int64, clients), latest: make([]atomic.Uint32, clients),
anchor: make([]atomic.Int64, clients), anchorTS: make([]atomic.Uint32, clients),
offset: make([]int64, clients), resolved: make([]bool, clients),
}
}
// segmentMessage is the type of a player's report that a whole segment came,
// after its frames; it is not media.
const segmentMessage byte = 0xff
// received counts a player's message, keeps its newest timestamp, and anchors
// the player to the source at its first video frame with a unique payload.
func (w *workerPlayers) received(id int, m rtmpMessage) {
if m.typeID == segmentMessage {
w.segments.Add(1)
return
}
w.frames.Add(1)
w.bytes.Add(int64(len(m.payload)))
w.latest[id].Store(m.timestamp)
if w.anchor[id].Load() == 0 {
if at, ok := w.timeline.inLoop(m); ok {
w.anchorTS[id].Store(m.timestamp)
w.anchor[id].Store(int64(at) + 1)
}
}
w.last[id].Store(time.Now().UnixNano())
}
// lag is how far a player's newest message is behind the publisher's newest
// one, both in publisher time; false until the player has an anchor.
func (w *workerPlayers) lag(id int, publisherTimestamp uint32) (int64, bool) {
if !w.resolved[id] {
anchor := w.anchor[id].Load()
if anchor == 0 || publisherTimestamp == 0 {
return 0, false
}
at := w.timeline.publisherTime(uint32(anchor-1), publisherTimestamp)
w.offset[id] = at - int64(w.anchorTS[id].Load())
w.resolved[id] = true
}
return int64(publisherTimestamp) - (int64(w.latest[id].Load()) + w.offset[id]), true
}
// player is one connected player of a scenario's protocol; play reports its
// audio and video messages until stop.
type player interface {
play(stream string, stop <-chan struct{}, report chan<- rtmpMessage) error
}
func (c *rtmpClient) play(stream string, stop <-chan struct{}, report chan<- rtmpMessage) error {
return playRTMP(c, stream, stop, report)
}
// playerDialer connects one player of a scenario's player protocol to SRS in
// the shared network namespace.
func playerDialer(kind string) (func() (player, error), error) {
switch kind {
case "rtmp":
return func() (player, error) {
c, err := dialRTMP("127.0.0.1:1935", "live")
if err != nil {
return nil, err
}
return c, nil
}, nil
case "flv":
return func() (player, error) {
c, err := dialFLV("127.0.0.1:8080", "live")
if err != nil {
return nil, err
}
return c, nil
}, nil
case "ts":
return func() (player, error) {
c, err := dialHTTPTS("127.0.0.1:"+httpTSPort, "live")
if err != nil {
return nil, err
}
return c, nil
}, nil
case "rtsp":
return func() (player, error) {
c, err := dialRTSP("127.0.0.1:"+rtspPort, "live")
if err != nil {
return nil, err
}
return c, nil
}, nil
case "srt":
return func() (player, error) {
c, err := dialSRT("127.0.0.1:"+srtPort, "live")
if err != nil {
return nil, err
}
return c, nil
}, nil
case "hls":
return func() (player, error) {
c, err := dialHLS("127.0.0.1:"+hlsPort, "live")
if err != nil {
return nil, err
}
return c, nil
}, nil
case "dash":
return func() (player, error) {
c, err := dialDASH("127.0.0.1:"+dashPort, "live")
if err != nil {
return nil, err
}
return c, nil
}, nil
case "whep":
return func() (player, error) {
c, err := dialWHEP("127.0.0.1:"+rtcAPIPort, "live")
if err != nil {
return nil, err
}
return c, nil
}, nil
}
return nil, fmt.Errorf("no player for protocol %q", kind)
}
// startPlayersOn starts the players, player id on stream streamOf(id) once
// wait, if any, lets it; wait returns false at stop.
func startPlayersOn(dial func() (player, error), clients int, timeline *sourceTimeline, gap time.Duration, streamOf func(int) string, wait func(id int, stop <-chan struct{}) bool) *workerPlayers {
w := newWorkerPlayers(clients, timeline)
w.gap = gap
// The first dial error of a worker names a load-side limit, such as no
// free ephemeral port or too many open files.
var logged atomic.Bool
for id := 0; id < clients; id++ {
w.wg.Add(1)
go func(id int) {
defer w.wg.Done()
if wait != nil && !wait(id, w.stop) {
return
}
c, err := dialPlayer(dial, w.stop)
if err != nil {
w.failures.Add(1)
if logged.CompareAndSwap(false, true) {
fmt.Fprintf(os.Stderr, "player dial: %v\n", err)
}
return
}
report := make(chan rtmpMessage, 128)
readDone := make(chan error, 1)
go func() { readDone <- c.play(streamOf(id), w.stop, report); close(report) }()
for m := range report {
w.received(id, m)
}
if err := <-readDone; err != nil {
w.failures.Add(1)
}
}(id)
}
return w
}
// dialPlayer dials again while a dial times out, as when a step adds thousands
// of players at once and SRS's listen backlog overflows. A player that never
// connects fails the frame rule; any other dial error ends it.
func dialPlayer[T any](dial func() (T, error), stop <-chan struct{}) (T, error) {
for {
c, err := dial()
var netErr net.Error
if err == nil || !errors.As(err, &netErr) || !netErr.Timeout() {
return c, err
}
select {
case <-stop:
var none T
return none, err
default:
}
}
}
func (w *workerPlayers) sample(timestamps []uint32, final bool) workerResponse {
r := workerResponse{Frames: w.frames.Swap(0), Segments: w.segments.Swap(0), Bytes: w.bytes.Swap(0), Failures: w.failures.Swap(0)}
now := time.Now()
for id := range w.last {
last := w.last[id].Load()
if last == 0 {
if final || now.Sub(w.started) > w.gap {
r.Failures++
}
continue
}
if now.Sub(time.Unix(0, last)) > w.gap {
r.Failures++
}
var newest uint32
if w.clocks != nil {
newest = w.clocks[id].get()
} else if stream := w.streamOf(id); stream < len(timestamps) {
newest = timestamps[stream]
}
if lag, ok := w.lag(id, newest); ok && lag > r.LagMillis {
r.LagMillis = lag
}
}
return r
}
func (w *workerPlayers) streamOf(id int) int {
if w.streams == nil {
return 0
}
return w.streams[id]
}
func (w *workerPlayers) close() { close(w.stop); w.wg.Wait() }