From b5848b6e3eccb07b81a23522e8421dfd5c09274c Mon Sep 17 00:00:00 2001 From: Finefinee Date: Sun, 12 Jul 2026 03:54:02 +0900 Subject: [PATCH 01/11] Add WebRTC load testing toolkit --- .env.example | 2 + .gitignore | 5 + app/api/routes/root.py | 9 + app/core/config.py | 3 + app/services/ai/filter.py | 8 +- app/services/ai/track.py | 83 ++- app/services/metrics.py | 58 ++ app/services/sessions/manager.py | 32 +- docker-compose.test.yml | 26 + loadtest/k6/sessions.js | 40 ++ loadtest/pion-client/README.md | 93 +++ loadtest/pion-client/client.go | 347 ++++++++++ loadtest/pion-client/client_test.go | 38 ++ loadtest/pion-client/config.go | 62 ++ loadtest/pion-client/go.mod | 31 + loadtest/pion-client/go.sum | 52 ++ loadtest/pion-client/main.go | 202 ++++++ loadtest/pion-client/media.go | 84 +++ loadtest/pion-client/media_test.go | 75 +++ loadtest/pion-client/results.go | 153 +++++ loadtest/pion-client/signaling.go | 131 ++++ loadtest/videos/README.md | 13 + .../grafana/dashboards/innolive.json | 39 ++ .../provisioning/dashboards/dashboards.yml | 7 + .../provisioning/datasources/prometheus.yml | 8 + observability/prometheus.yml | 8 + requirements.txt | 1 + test-docs/test-backlog.md | 86 +++ test-docs/test-plan.md | 637 ++++++++++++++++++ test-docs/test-result.md | 27 + test-docs/test-runbook.md | 63 ++ tests/test_api_sessions.py | 9 + tests/test_wrap_with_ai.py | 22 + 33 files changed, 2444 insertions(+), 10 deletions(-) create mode 100644 app/services/metrics.py create mode 100644 docker-compose.test.yml create mode 100644 loadtest/k6/sessions.js create mode 100644 loadtest/pion-client/README.md create mode 100644 loadtest/pion-client/client.go create mode 100644 loadtest/pion-client/client_test.go create mode 100644 loadtest/pion-client/config.go create mode 100644 loadtest/pion-client/go.mod create mode 100644 loadtest/pion-client/go.sum create mode 100644 loadtest/pion-client/main.go create mode 100644 loadtest/pion-client/media.go create mode 100644 loadtest/pion-client/media_test.go create mode 100644 loadtest/pion-client/results.go create mode 100644 loadtest/pion-client/signaling.go create mode 100644 loadtest/videos/README.md create mode 100644 observability/grafana/dashboards/innolive.json create mode 100644 observability/grafana/provisioning/dashboards/dashboards.yml create mode 100644 observability/grafana/provisioning/datasources/prometheus.yml create mode 100644 observability/prometheus.yml create mode 100644 test-docs/test-backlog.md create mode 100644 test-docs/test-plan.md create mode 100644 test-docs/test-result.md create mode 100644 test-docs/test-runbook.md diff --git a/.env.example b/.env.example index eb99246..a8ada78 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,8 @@ WEBRTC_UDP_PORT_MIN=50000 WEBRTC_UDP_PORT_MAX=60000 WEBRTC_DISCONNECTED_GRACE_SECONDS=10 AI_PRIVACY_ENABLED=true +AI_PRIVACY_MODE=real +AI_PRIVACY_FIXED_DELAY_MS=20 AI_PRIVACY_DEVICE=cuda:0 AI_PRIVACY_REQUIRE_GPU=true AI_PRIVACY_USE_HALF=true diff --git a/.gitignore b/.gitignore index 2ac5406..a7a2b62 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,8 @@ htmlcov/ .DS_Store .idea data/ +loadtest/pion-client/results/ +loadtest/pion-client/.gocache/ +loadtest/pion-client/*.exe +loadtest/videos/* +!loadtest/videos/README.md diff --git a/app/api/routes/root.py b/app/api/routes/root.py index b1612e2..13dfb88 100644 --- a/app/api/routes/root.py +++ b/app/api/routes/root.py @@ -1,6 +1,9 @@ from fastapi import APIRouter +from fastapi.responses import Response +from prometheus_client import CONTENT_TYPE_LATEST, generate_latest from app.services.ai.filter import get_privacy_runtime_stats +from app.services.metrics import update_runtime_metrics router = APIRouter() @@ -17,3 +20,9 @@ async def health() -> dict[str, object]: "status": "ready", "ai_runtimes": get_privacy_runtime_stats(), } + + +@router.get("/metrics", include_in_schema=False) +async def metrics() -> Response: + update_runtime_metrics(get_privacy_runtime_stats()) + return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST) diff --git a/app/core/config.py b/app/core/config.py index d3cad36..bd7dc5a 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,4 +1,5 @@ from functools import lru_cache +from typing import Literal from pydantic_settings import BaseSettings, SettingsConfigDict @@ -16,6 +17,8 @@ class Settings(BaseSettings): webrtc_udp_port_max: int | None = 60000 webrtc_disconnected_grace_seconds: float = 10.0 ai_privacy_enabled: bool = True + ai_privacy_mode: Literal["real", "bypass", "fixed_delay"] = "real" + ai_privacy_fixed_delay_ms: float = 20.0 ai_privacy_device: str | None = None ai_privacy_require_gpu: bool = False ai_privacy_use_half: bool = True diff --git a/app/services/ai/filter.py b/app/services/ai/filter.py index 3bc0317..0ecbd5c 100644 --- a/app/services/ai/filter.py +++ b/app/services/ai/filter.py @@ -108,9 +108,13 @@ def _build_config() -> PrivacyBlurConfig | None: return _config settings = get_settings() - if not settings.ai_privacy_enabled: + if not settings.ai_privacy_enabled or settings.ai_privacy_mode != "real": _config_initialized = True - logger.info("AI privacy filter is disabled") + logger.info( + "AI privacy runtime is disabled: enabled=%s mode=%s", + settings.ai_privacy_enabled, + settings.ai_privacy_mode, + ) return None _config = build_privacy_config(settings) diff --git a/app/services/ai/track.py b/app/services/ai/track.py index 215e386..e6a6c07 100644 --- a/app/services/ai/track.py +++ b/app/services/ai/track.py @@ -2,12 +2,21 @@ import asyncio import logging +from time import perf_counter from typing import TYPE_CHECKING import numpy as np from aiortc.mediastreams import MediaStreamTrack, VideoStreamTrack from av import VideoFrame +from app.services.metrics import ( + FRAME_FAILURES, + FRAME_PROCESSING_DURATION, + FRAMES_DROPPED, + FRAMES_PROCESSED, + FRAMES_RECEIVED, +) + if TYPE_CHECKING: from privacy_blur import FacePrivacyFilter @@ -35,21 +44,36 @@ def is_fallback_active(self) -> bool: return self._fallback async def recv(self): - frame = await _recv_latest_frame(self._source) + frame, dropped = await _recv_latest_frame_with_drop_count(self._source) + mode = "real" + FRAMES_RECEIVED.labels(mode).inc() + if dropped: + FRAMES_DROPPED.labels(mode).inc(dropped) + started = perf_counter() if self._fallback: - return _blackout_frame(frame) + result = _blackout_frame(frame) + FRAMES_PROCESSED.labels(mode).inc() + FRAME_PROCESSING_DURATION.labels(mode).observe(perf_counter() - started) + return result try: assert self._filter is not None - return await self._filter.apply_async(frame) + result = await self._filter.apply_async(frame) + FRAMES_PROCESSED.labels(mode).inc() + FRAME_PROCESSING_DURATION.labels(mode).observe(perf_counter() - started) + return result except Exception: self._fallback = True + FRAME_FAILURES.labels(mode).inc() logger.warning( "AI filter failed, blocking video instead of exposing raw frames: " "session_id=%s", self._session_id, exc_info=True, ) - return _blackout_frame(frame) + result = _blackout_frame(frame) + FRAMES_PROCESSED.labels(mode).inc() + FRAME_PROCESSING_DURATION.labels(mode).observe(perf_counter() - started) + return result def stop(self) -> None: super().stop() @@ -69,10 +93,17 @@ def stop(self) -> None: async def _recv_latest_frame(source: MediaStreamTrack) -> VideoFrame: + frame, _ = await _recv_latest_frame_with_drop_count(source) + return frame + + +async def _recv_latest_frame_with_drop_count( + source: MediaStreamTrack, +) -> tuple[VideoFrame, int]: frame = await source.recv() queue = getattr(source, "_queue", None) if not isinstance(queue, asyncio.Queue): - return frame + return frame, 0 dropped = 0 while True: @@ -94,7 +125,47 @@ async def _recv_latest_frame(source: MediaStreamTrack) -> VideoFrame: "Dropped stale source frames before AI processing: count=%d", dropped, ) - return frame + return frame, dropped + + +class BenchmarkVideoTrack(VideoStreamTrack): + kind = "video" + + def __init__( + self, + source: MediaStreamTrack, + *, + session_id: str, + mode: str, + delay_ms: float = 0.0, + ) -> None: + super().__init__() + self._source = source + self._session_id = session_id + self._mode = mode + self._delay_seconds = max(delay_ms, 0.0) / 1000.0 + + @property + def is_fallback_active(self) -> bool: + return False + + async def recv(self) -> VideoFrame: + frame, dropped = await _recv_latest_frame_with_drop_count(self._source) + FRAMES_RECEIVED.labels(self._mode).inc() + if dropped: + FRAMES_DROPPED.labels(self._mode).inc(dropped) + started = perf_counter() + if self._delay_seconds: + await asyncio.sleep(self._delay_seconds) + FRAMES_PROCESSED.labels(self._mode).inc() + FRAME_PROCESSING_DURATION.labels(self._mode).observe( + perf_counter() - started + ) + return frame + + def stop(self) -> None: + super().stop() + self._source.stop() def _blackout_frame(frame: VideoFrame) -> VideoFrame: diff --git a/app/services/metrics.py b/app/services/metrics.py new file mode 100644 index 0000000..1b49d72 --- /dev/null +++ b/app/services/metrics.py @@ -0,0 +1,58 @@ +from prometheus_client import Counter, Gauge, Histogram + + +ACTIVE_SESSIONS = Gauge( + "innolive_active_sessions", + "Number of active WebRTC sessions.", +) +CONNECTIONS = Counter( + "innolive_connection_total", + "Number of WebRTC sessions created.", +) +CONNECTION_FAILURES = Counter( + "innolive_connection_failures_total", + "Number of WebRTC connections that reached a failed state.", +) +FRAMES_RECEIVED = Counter( + "innolive_frame_received_total", + "Number of video frames received for processing.", + ("mode",), +) +FRAMES_PROCESSED = Counter( + "innolive_frame_processed_total", + "Number of video frames returned by the processing track.", + ("mode",), +) +FRAMES_DROPPED = Counter( + "innolive_frame_dropped_total", + "Number of stale source frames discarded before processing.", + ("mode",), +) +FRAME_FAILURES = Counter( + "innolive_frame_failures_total", + "Number of frame-processing failures.", + ("mode",), +) +FRAME_PROCESSING_DURATION = Histogram( + "innolive_frame_processing_duration_seconds", + "Time spent processing one frame.", + ("mode",), + buckets=(0.005, 0.01, 0.02, 0.033, 0.05, 0.075, 0.1, 0.2, 0.5, 1.0), +) +PROCESSING_QUEUE_SIZE = Gauge( + "innolive_processing_queue_size", + "Current shared AI processing queue depth.", +) +RECONNECTS = Counter( + "innolive_reconnect_total", + "Number of WebRTC sessions that recovered after disconnecting.", +) + + +def update_runtime_metrics(stats: list[dict[str, object]]) -> None: + queue_depth = 0 + for runtime in stats: + value = runtime.get("queue_depth", 0) + if isinstance(value, int | float): + queue_depth += float(value) + PROCESSING_QUEUE_SIZE.set(queue_depth) diff --git a/app/services/sessions/manager.py b/app/services/sessions/manager.py index 90f7ce3..bcbf349 100644 --- a/app/services/sessions/manager.py +++ b/app/services/sessions/manager.py @@ -11,6 +11,12 @@ from app.core.config import get_settings from app.core.exceptions import BadRequestError, ConflictError, NotFoundError from app.services.rtmp.state import StreamState +from app.services.metrics import ( + ACTIVE_SESSIONS, + CONNECTION_FAILURES, + CONNECTIONS, + RECONNECTS, +) from app.services.webrtc.metrics import PeerConnectionTiming from app.services.webrtc.peer_connection import create_peer_connection @@ -124,6 +130,8 @@ async def create_session( async with self._lock: self._sessions[session_id] = session + ACTIVE_SESSIONS.set(len(self._sessions)) + CONNECTIONS.inc() logger.info("Created live session: session_id=%s", session_id) return session @@ -272,6 +280,7 @@ async def close_all(self) -> None: async with self._lock: sessions = list(self._sessions.values()) self._sessions.clear() + ACTIVE_SESSIONS.set(0) for session in sessions: if session.disconnected_cleanup_task is not None: @@ -300,6 +309,8 @@ async def handle_peer_connection_state_change( return if connection_state in TERMINAL_CONNECTION_STATES: + if connection_state == "failed": + CONNECTION_FAILURES.inc() await self.close_session( session_id, reason=f"peer_connection_{connection_state}", @@ -328,6 +339,7 @@ async def close_session( session.status = "closing" self._sessions.pop(session_id, None) + ACTIVE_SESSIONS.set(len(self._sessions)) cleanup_task = session.disconnected_cleanup_task session.disconnected_cleanup_task = None @@ -536,6 +548,7 @@ async def _cancel_disconnected_cleanup( if cleanup_task is not None and not cleanup_task.done(): cleanup_task.cancel() + RECONNECTS.inc() logger.info( "Cancelled disconnected session cleanup: session_id=%s " "connection_state=%s", @@ -882,9 +895,24 @@ def _wrap_with_ai( from app.core.config import get_settings from app.services.ai.filter import get_privacy_filter - privacy_filter = get_privacy_filter(client_id=client_id) - if privacy_filter is None and not get_settings().ai_privacy_enabled: + settings = get_settings() + if not settings.ai_privacy_enabled: return track + if settings.ai_privacy_mode in {"bypass", "fixed_delay"}: + from app.services.ai.track import BenchmarkVideoTrack + + return BenchmarkVideoTrack( + source=track, + session_id=session_id, + mode=settings.ai_privacy_mode, + delay_ms=( + settings.ai_privacy_fixed_delay_ms + if settings.ai_privacy_mode == "fixed_delay" + else 0.0 + ), + ) + + privacy_filter = get_privacy_filter(client_id=client_id) from app.services.ai.track import ProtectedVideoTrack diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..3894102 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,26 @@ +services: + prometheus: + image: prom/prometheus:v3.7.3 + command: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.retention.time=7d + volumes: + - ./observability/prometheus.yml:/etc/prometheus/prometheus.yml:ro + extra_hosts: + - host.docker.internal:host-gateway + ports: + - "9090:9090" + + grafana: + image: grafana/grafana:12.3.1 + environment: + GF_SECURITY_ADMIN_USER: admin + GF_SECURITY_ADMIN_PASSWORD: admin + GF_USERS_ALLOW_SIGN_UP: "false" + volumes: + - ./observability/grafana/provisioning:/etc/grafana/provisioning:ro + - ./observability/grafana/dashboards:/var/lib/grafana/dashboards:ro + ports: + - "3000:3000" + depends_on: + - prometheus diff --git a/loadtest/k6/sessions.js b/loadtest/k6/sessions.js new file mode 100644 index 0000000..f6e4f28 --- /dev/null +++ b/loadtest/k6/sessions.js @@ -0,0 +1,40 @@ +import http from "k6/http"; +import { check, sleep } from "k6"; + +const baseUrl = __ENV.BASE_URL || "http://127.0.0.1:8000"; + +export const options = { + scenarios: { + session_control: { + executor: "ramping-vus", + startVUs: 0, + stages: [ + { duration: "30s", target: Number(__ENV.VUS || 10) }, + { duration: __ENV.HOLD || "2m", target: Number(__ENV.VUS || 10) }, + { duration: "15s", target: 0 } + ] + } + }, + thresholds: { + http_req_failed: ["rate<0.01"], + http_req_duration: ["p(95)<500"] + } +}; + +export default function () { + const create = http.post( + `${baseUrl}/sessions`, + JSON.stringify({ metadata: { source: "k6", vu: String(__VU) } }), + { headers: { "Content-Type": "application/json" } } + ); + check(create, { "session created": (response) => response.status === 201 }); + if (create.status !== 201) return; + + const sessionId = create.json("session_id"); + const read = http.get(`${baseUrl}/sessions/${sessionId}`); + check(read, { "session readable": (response) => response.status === 200 }); + sleep(Number(__ENV.SESSION_SECONDS || 5)); + + const remove = http.del(`${baseUrl}/sessions/${sessionId}`); + check(remove, { "session deleted": (response) => response.status === 204 }); +} diff --git a/loadtest/pion-client/README.md b/loadtest/pion-client/README.md new file mode 100644 index 0000000..53b21aa --- /dev/null +++ b/loadtest/pion-client/README.md @@ -0,0 +1,93 @@ +# InnoLive Pion media load client + +This client creates real InnoLive sessions and one Pion PeerConnection per +virtual broadcaster. Every client loops the same VP8/IVF file, receives the +processed video track, exchanges offer/answer and trickle ICE over the server's +signaling WebSocket, and writes interval samples to JSON and CSV. + +## Prerequisites + +- Go 1.23 or newer +- FFmpeg (only needed to prepare the input) +- A running InnoLive server + +Create a deterministic 720p/30 FPS input from any MP4: + +```bash +mkdir -p loadtest/videos +ffmpeg -i input.mp4 -an -vf "scale=1280:720,fps=30" \ + -c:v libvpx -deadline good -cpu-used 2 -b:v 2M \ + loadtest/videos/720p30.ivf +``` + +The load client intentionally accepts IVF rather than MP4. IVF contains VP8 +frames Pion can packetize directly, so decoding and re-encoding in the load +generator does not consume CPU and distort the server-capacity result. + +## Build + +```bash +cd loadtest/pion-client +go mod download +go build -o pion-load . +``` + +## Run scenarios + +Step load (add five streams every two minutes): + +```bash +./pion-load \ + -base-url http://127.0.0.1:8000 \ + -input ../videos/720p30.ivf \ + -scenario step -clients 50 -start-clients 5 \ + -step-size 5 -step-duration 2m \ + -output results/step-720p30 +``` + +Spike load (reach 50 streams in 10 seconds and hold for five minutes): + +```bash +./pion-load -base-url http://127.0.0.1:8000 \ + -input ../videos/720p30.ivf -scenario spike -clients 50 \ + -ramp-duration 10s -duration 5m -output results/spike-720p30 +``` + +Soak load (gradually reach 30 streams and hold for two hours): + +```bash +./pion-load -base-url http://127.0.0.1:8000 \ + -input ../videos/720p30.ivf -scenario soak -clients 30 \ + -start-clients 1 -ramp-duration 5m -duration 2h \ + -output results/soak-720p30 +``` + +Press Ctrl+C for a graceful stop. Active PeerConnections and REST sessions are +closed, then partial results are still written. + +## Output fields + +Both files contain one row per active client at each `-stats-interval` (default +5 seconds): + +- connection and ICE state, success/failure, and last error +- sent and received frame totals plus interval send/receive FPS +- received RTP packets, inferred missing packets, and cumulative loss percent +- RFC 3550 receive jitter in milliseconds +- RTT in milliseconds derived from RTCP receiver reports +- received payload bytes + +RTT is `0` until the first usable RTCP receiver report arrives. Packet loss and +jitter describe the processed video received by the client. The JSON file also +contains the complete command configuration. + +## Capacity-test notes + +- Run the load generator on a different machine for final capacity numbers. +- Keep input, codec, resolution, FPS, server mode, and network conditions fixed. +- Check server Prometheus/Grafana metrics alongside the client CSV. +- Start with `AI_PRIVACY_MODE=bypass`, then `fixed_delay`, then `real` to isolate + WebRTC overhead from AI cost. +- A single process is suitable for dozens to low hundreds of connections; + distribute identical runs across load-generator machines if that host itself + becomes CPU- or network-bound. diff --git a/loadtest/pion-client/client.go b/loadtest/pion-client/client.go new file mode 100644 index 0000000..7ea6e04 --- /dev/null +++ b/loadtest/pion-client/client.go @@ -0,0 +1,347 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "sync" + "sync/atomic" + "time" + + "github.com/gorilla/websocket" + "github.com/pion/rtcp" + "github.com/pion/webrtc/v4" +) + +type receiveCounters struct { + frames atomic.Uint64 + packets atomic.Uint64 + bytes atomic.Uint64 + lost atomic.Uint64 + jitterMicros atomic.Uint64 + rttMicros atomic.Uint64 +} + +type loadClient struct { + ordinal int + sessionID string + startedAt time.Time + connected atomic.Bool + failed atomic.Bool + errorMu sync.RWMutex + errorText string + stateMu sync.RWMutex + pcState string + iceState string + sent mediaCounters + received receiveCounters + cancel context.CancelFunc + pc *webrtc.PeerConnection + ws *signalSocket + http *http.Client + baseURL string + closeOnce sync.Once +} + +func startLoadClient(parent context.Context, cfg config, ordinal int, iceServers []webrtc.ICEServer, httpClient *http.Client) (*loadClient, error) { + ctx, cancel := context.WithCancel(parent) + c := &loadClient{ordinal: ordinal, startedAt: time.Now(), pcState: "new", iceState: "new", cancel: cancel, http: httpClient, baseURL: cfg.BaseURL} + + setupCtx, setupCancel := context.WithTimeout(ctx, cfg.ConnectTimeout) + defer setupCancel() + var err error + c.sessionID, err = createSession(setupCtx, httpClient, cfg.BaseURL, ordinal) + if err != nil { + cancel() + return nil, err + } + cleanup := func() { + c.Close() + } + + wsURL, err := websocketURL(cfg.BaseURL) + if err != nil { + cleanup() + return nil, err + } + conn, _, err := websocket.DefaultDialer.DialContext(setupCtx, wsURL, nil) + if err != nil { + cleanup() + return nil, fmt.Errorf("open signaling websocket: %w", err) + } + c.ws = &signalSocket{conn: conn} + + pc, err := webrtc.NewPeerConnection(webrtc.Configuration{ICEServers: iceServers}) + if err != nil { + cleanup() + return nil, err + } + c.pc = pc + connectedCh := make(chan struct{}, 1) + answerCh := make(chan error, 1) + c.installHandlers(ctx, cfg, connectedCh) + + track, err := webrtc.NewTrackLocalStaticSample( + webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8, ClockRate: 90000}, + "video", fmt.Sprintf("load-%d", ordinal), + ) + if err != nil { + cleanup() + return nil, err + } + transceiver, err := pc.AddTransceiverFromTrack(track, webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionSendrecv}) + if err != nil { + cleanup() + return nil, fmt.Errorf("add video transceiver: %w", err) + } + go c.readSenderRTCP(ctx, transceiver.Sender()) + go c.readSignaling(ctx, answerCh) + + offer, err := pc.CreateOffer(nil) + if err == nil { + err = pc.SetLocalDescription(offer) + } + if err == nil { + err = c.ws.write(map[string]any{"type": "offer", "session_id": c.sessionID, "sdp": offer.SDP}) + } + if err != nil { + cleanup() + return nil, fmt.Errorf("send offer: %w", err) + } + + select { + case err = <-answerCh: + if err != nil { + cleanup() + return nil, err + } + case <-setupCtx.Done(): + cleanup() + return nil, fmt.Errorf("wait for answer: %w", setupCtx.Err()) + } + select { + case <-connectedCh: + case <-setupCtx.Done(): + cleanup() + return nil, fmt.Errorf("wait for WebRTC connection: %w", setupCtx.Err()) + } + go func() { + if err := streamIVF(ctx, cfg.Input, cfg.FPS, track, &c.sent); err != nil && !errors.Is(err, context.Canceled) { + c.setError(err) + } + }() + return c, nil +} + +func (c *loadClient) installHandlers(ctx context.Context, cfg config, connectedCh chan<- struct{}) { + c.pc.OnICECandidate(func(candidate *webrtc.ICECandidate) { + if c.ws == nil { + return + } + message := signalMessage{Type: "ice_candidate", SessionID: c.sessionID} + if candidate != nil { + jsonCandidate := candidate.ToJSON() + message.Candidate = &jsonCandidate.Candidate + message.SDPMid = jsonCandidate.SDPMid + message.SDPMLineIndex = jsonCandidate.SDPMLineIndex + } + if err := c.ws.write(message); err != nil && ctx.Err() == nil { + c.setError(fmt.Errorf("send ICE candidate: %w", err)) + } + }) + c.pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) { + c.stateMu.Lock() + c.pcState = state.String() + c.stateMu.Unlock() + if state == webrtc.PeerConnectionStateConnected && c.connected.CompareAndSwap(false, true) { + select { + case connectedCh <- struct{}{}: + default: + } + } + if state == webrtc.PeerConnectionStateFailed { + c.setError(fmt.Errorf("PeerConnection failed")) + } + }) + c.pc.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) { + c.stateMu.Lock() + c.iceState = state.String() + c.stateMu.Unlock() + }) + c.pc.OnTrack(func(track *webrtc.TrackRemote, _ *webrtc.RTPReceiver) { + if track.Kind() == webrtc.RTPCodecTypeVideo { + go c.readProcessedTrack(ctx, track) + } + }) +} + +func (c *loadClient) readSignaling(ctx context.Context, answerCh chan<- error) { + answered := false + for { + var message signalMessage + if err := c.ws.conn.ReadJSON(&message); err != nil { + if ctx.Err() == nil { + c.setError(fmt.Errorf("read signaling: %w", err)) + } + if !answered { + select { + case answerCh <- err: + default: + } + } + return + } + switch message.Type { + case "answer": + if message.SessionID != c.sessionID { + continue + } + err := c.pc.SetRemoteDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeAnswer, SDP: message.SDP}) + answered = true + select { + case answerCh <- err: + default: + } + case "error": + var payload map[string]any + _ = json.Unmarshal(message.Error, &payload) + err := fmt.Errorf("signaling error: %v", payload) + c.setError(err) + if !answered { + select { + case answerCh <- err: + default: + } + } + } + } +} + +func (c *loadClient) readProcessedTrack(ctx context.Context, track *webrtc.TrackRemote) { + var base, max uint16 + var cycles uint64 + var received uint64 + var initialized bool + var transit int64 + var jitter float64 + clockRate := int64(track.Codec().ClockRate) + for { + packet, _, err := track.ReadRTP() + if err != nil { + if ctx.Err() == nil && !errors.Is(err, io.EOF) { + c.setError(fmt.Errorf("read processed RTP: %w", err)) + } + return + } + arrival := time.Now().UnixNano() * clockRate / int64(time.Second) + currentTransit := arrival - int64(packet.Timestamp) + if initialized { + d := currentTransit - transit + if d < 0 { + d = -d + } + jitter += (float64(d) - jitter) / 16 + if packet.SequenceNumber < max && max-packet.SequenceNumber > 0x8000 { + cycles += 1 << 16 + max = packet.SequenceNumber + } else if packet.SequenceNumber > max && packet.SequenceNumber-max < 0x8000 { + max = packet.SequenceNumber + } + } else { + base, max, initialized = packet.SequenceNumber, packet.SequenceNumber, true + } + transit = currentTransit + received++ + expected := cycles + uint64(max) - uint64(base) + 1 + if expected > received { + c.received.lost.Store(expected - received) + } else { + c.received.lost.Store(0) + } + c.received.jitterMicros.Store(uint64(jitter * 1_000_000 / float64(clockRate))) + c.received.packets.Add(1) + c.received.bytes.Add(uint64(len(packet.Payload))) + if packet.Marker { + c.received.frames.Add(1) + } + } +} + +func (c *loadClient) readSenderRTCP(ctx context.Context, sender *webrtc.RTPSender) { + for { + packets, _, err := sender.ReadRTCP() + if err != nil { + return + } + for _, packet := range packets { + report, ok := packet.(*rtcp.ReceiverReport) + if !ok { + continue + } + for _, reception := range report.Reports { + if reception.LastSenderReport == 0 { + continue + } + now := compactNTP(time.Now()) + rtt := now - reception.LastSenderReport - reception.Delay + if rtt < 10*65536 { + c.received.rttMicros.Store(uint64(rtt) * 1_000_000 / 65536) + } + } + } + if ctx.Err() != nil { + return + } + } +} + +func compactNTP(t time.Time) uint32 { + const ntpEpochOffset = 2208988800 + seconds := uint64(t.Unix()+ntpEpochOffset) & 0xffff + fraction := uint64(t.Nanosecond()) * (1 << 32) / uint64(time.Second) + return uint32(seconds<<16 | (fraction >> 16)) +} + +func (c *loadClient) setError(err error) { + if err == nil { + return + } + c.failed.Store(true) + c.errorMu.Lock() + c.errorText = err.Error() + c.errorMu.Unlock() + log.Printf("client=%d session=%s error=%v", c.ordinal, c.sessionID, err) +} + +func (c *loadClient) states() (string, string, string) { + c.stateMu.RLock() + pc, ice := c.pcState, c.iceState + c.stateMu.RUnlock() + c.errorMu.RLock() + errText := c.errorText + c.errorMu.RUnlock() + return pc, ice, errText +} + +func (c *loadClient) Close() { + c.closeOnce.Do(func() { + c.cancel() + if c.pc != nil { + _ = c.pc.Close() + } + if c.ws != nil { + _ = c.ws.conn.Close() + } + if c.sessionID != "" { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := deleteSession(ctx, c.http, c.baseURL, c.sessionID); err != nil { + c.setError(err) + } + } + }) +} diff --git a/loadtest/pion-client/client_test.go b/loadtest/pion-client/client_test.go new file mode 100644 index 0000000..1fbc1b2 --- /dev/null +++ b/loadtest/pion-client/client_test.go @@ -0,0 +1,38 @@ +package main + +import ( + "strings" + "testing" + "time" +) + +func TestWebsocketURL(t *testing.T) { + got, err := websocketURL("https://example.test:8443/api") + if err != nil { + t.Fatal(err) + } + if got != "wss://example.test:8443/signaling" { + t.Fatalf("unexpected URL: %s", got) + } +} + +func TestCompactNTPAdvancesAt65536TicksPerSecond(t *testing.T) { + start := time.Unix(1_700_000_000, 0) + delta := compactNTP(start.Add(time.Second)) - compactNTP(start) + if delta != 65536 { + t.Fatalf("unexpected compact NTP delta: %d", delta) + } +} + +func TestRecorderWritesJSONAndCSV(t *testing.T) { + dir := t.TempDir() + r := newRecorder(config{Scenario: "step"}) + r.rows = append(r.rows, sampleRow{Timestamp: time.Now(), Client: 1, SessionID: "test"}) + jsonPath, csvPath, err := r.write(dir + "/result") + if err != nil { + t.Fatal(err) + } + if !strings.HasSuffix(jsonPath, ".json") || !strings.HasSuffix(csvPath, ".csv") { + t.Fatalf("unexpected result paths: %s %s", jsonPath, csvPath) + } +} diff --git a/loadtest/pion-client/config.go b/loadtest/pion-client/config.go new file mode 100644 index 0000000..35dbb8a --- /dev/null +++ b/loadtest/pion-client/config.go @@ -0,0 +1,62 @@ +package main + +import ( + "flag" + "fmt" + "strings" + "time" +) + +type config struct { + BaseURL string + Input string + Scenario string + Clients int + StartClients int + StepSize int + StepDuration time.Duration + RampDuration time.Duration + Duration time.Duration + StatsInterval time.Duration + ConnectTimeout time.Duration + FPS float64 + Output string +} + +func parseConfig() (config, error) { + var cfg config + flag.StringVar(&cfg.BaseURL, "base-url", "http://127.0.0.1:8000", "InnoLive HTTP base URL") + flag.StringVar(&cfg.Input, "input", "", "VP8 IVF video file (required)") + flag.StringVar(&cfg.Scenario, "scenario", "step", "load shape: step, spike, or soak") + flag.IntVar(&cfg.Clients, "clients", 10, "maximum concurrent PeerConnections") + flag.IntVar(&cfg.StartClients, "start-clients", 1, "initial clients for step scenario") + flag.IntVar(&cfg.StepSize, "step-size", 1, "clients added at each step") + flag.DurationVar(&cfg.StepDuration, "step-duration", 2*time.Minute, "time held at each step") + flag.DurationVar(&cfg.RampDuration, "ramp-duration", 30*time.Second, "time to reach target in spike/soak") + flag.DurationVar(&cfg.Duration, "duration", 10*time.Minute, "hold time after ramp (spike/soak)") + flag.DurationVar(&cfg.StatsInterval, "stats-interval", 5*time.Second, "statistics sampling interval") + flag.DurationVar(&cfg.ConnectTimeout, "connect-timeout", 30*time.Second, "per-client connection timeout") + flag.Float64Var(&cfg.FPS, "fps", 0, "override input FPS; 0 uses IVF header") + flag.StringVar(&cfg.Output, "output", "results/pion-load", "output path prefix (.json and .csv are appended)") + flag.Parse() + + cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/") + if cfg.Input == "" { + return cfg, fmt.Errorf("-input is required") + } + if cfg.Clients < 1 || cfg.StartClients < 0 || cfg.StartClients > cfg.Clients { + return cfg, fmt.Errorf("clients must be >= 1 and start-clients must be between 0 and clients") + } + if cfg.StepSize < 1 || cfg.StepDuration <= 0 || cfg.RampDuration < 0 || cfg.Duration <= 0 { + return cfg, fmt.Errorf("scenario counts and durations must be positive") + } + if cfg.StatsInterval <= 0 || cfg.ConnectTimeout <= 0 || cfg.FPS < 0 { + return cfg, fmt.Errorf("stats/connect durations must be positive and fps cannot be negative") + } + switch cfg.Scenario { + case "step", "spike", "soak": + default: + return cfg, fmt.Errorf("unsupported scenario %q", cfg.Scenario) + } + return cfg, nil +} diff --git a/loadtest/pion-client/go.mod b/loadtest/pion-client/go.mod new file mode 100644 index 0000000..2a65df4 --- /dev/null +++ b/loadtest/pion-client/go.mod @@ -0,0 +1,31 @@ +module innolive/loadtest/pion-client + +go 1.23.0 + +require ( + github.com/gorilla/websocket v1.5.3 + github.com/pion/rtcp v1.2.15 + github.com/pion/webrtc/v4 v4.1.2 +) + +require ( + github.com/google/uuid v1.6.0 // indirect + github.com/pion/datachannel v1.5.10 // indirect + github.com/pion/dtls/v3 v3.0.6 // indirect + github.com/pion/ice/v4 v4.0.10 // indirect + github.com/pion/interceptor v0.1.40 // indirect + github.com/pion/logging v0.2.3 // indirect + github.com/pion/mdns/v2 v2.0.7 // indirect + github.com/pion/randutil v0.1.0 // indirect + github.com/pion/rtp v1.8.22 // indirect + github.com/pion/sctp v1.8.39 // indirect + github.com/pion/sdp/v3 v3.0.13 // indirect + github.com/pion/srtp/v3 v3.0.5 // indirect + github.com/pion/stun/v3 v3.0.0 // indirect + github.com/pion/transport/v3 v3.0.7 // indirect + github.com/pion/turn/v4 v4.0.0 // indirect + github.com/wlynxg/anet v0.0.5 // indirect + golang.org/x/crypto v0.33.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/sys v0.30.0 // indirect +) diff --git a/loadtest/pion-client/go.sum b/loadtest/pion-client/go.sum new file mode 100644 index 0000000..f1a1d40 --- /dev/null +++ b/loadtest/pion-client/go.sum @@ -0,0 +1,52 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o= +github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M= +github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E= +github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU= +github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4= +github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= +github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4= +github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic= +github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI= +github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90= +github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM= +github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA= +github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= +github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= +github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo= +github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0= +github.com/pion/rtp v1.8.22 h1:8NCVDDF+uSJmMUkjLJVnIr/HX7gPesyMV1xFt5xozXc= +github.com/pion/rtp v1.8.22/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM= +github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE= +github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE= +github.com/pion/sdp/v3 v3.0.13 h1:uN3SS2b+QDZnWXgdr69SM8KB4EbcnPnPf2Laxhty/l4= +github.com/pion/sdp/v3 v3.0.13/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E= +github.com/pion/srtp/v3 v3.0.5 h1:8XLB6Dt3QXkMkRFpoqC3314BemkpMQK2mZeJc4pUKqo= +github.com/pion/srtp/v3 v3.0.5/go.mod h1:r1G7y5r1scZRLe2QJI/is+/O83W2d+JoEsuIexpw+uM= +github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw= +github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU= +github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= +github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= +github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM= +github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA= +github.com/pion/webrtc/v4 v4.1.2 h1:mpuUo/EJ1zMNKGE79fAdYNFZBX790KE7kQQpLMjjR54= +github.com/pion/webrtc/v4 v4.1.2/go.mod h1:xsCXiNAmMEjIdFxAYU0MbB3RwRieJsegSB2JZsGN+8U= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= +github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/loadtest/pion-client/main.go b/loadtest/pion-client/main.go new file mode 100644 index 0000000..c477c96 --- /dev/null +++ b/loadtest/pion-client/main.go @@ -0,0 +1,202 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "sync" + "syscall" + "time" + + "github.com/pion/webrtc/v4" +) + +type controller struct { + cfg config + ctx context.Context + http *http.Client + iceServers []webrtc.ICEServer + recorder *recorder + mu sync.RWMutex + clients []*loadClient + attempts []*loadClient + target int + nextOrdinal int +} + +func main() { + cfg, err := parseConfig() + if err != nil { + log.Fatal(err) + } + file, _, _, err := openIVF(cfg.Input, cfg.FPS) + if err != nil { + log.Fatal(err) + } + _ = file.Close() + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + httpClient := &http.Client{Timeout: cfg.ConnectTimeout} + iceServers, err := fetchICEServers(ctx, httpClient, cfg.BaseURL) + if err != nil { + log.Fatal(err) + } + c := &controller{cfg: cfg, ctx: ctx, http: httpClient, iceServers: iceServers, recorder: newRecorder(cfg)} + done := make(chan struct{}) + go func() { c.collectStats(); close(done) }() + + err = c.runScenario() + c.snapshot() + c.closeAll() + stop() + <-done + jsonPath, csvPath, writeErr := c.recorder.write(cfg.Output) + if writeErr != nil { + log.Fatalf("write results: %v", writeErr) + } + log.Printf("results: %s, %s", jsonPath, csvPath) + if err != nil && !errors.Is(err, context.Canceled) { + log.Fatal(err) + } +} + +func (c *controller) runScenario() error { + log.Printf("scenario=%s clients=%d input=%s", c.cfg.Scenario, c.cfg.Clients, c.cfg.Input) + switch c.cfg.Scenario { + case "step": + start := c.cfg.StartClients + if start == 0 { + start = c.cfg.StepSize + } + for target := start; ; target += c.cfg.StepSize { + if target > c.cfg.Clients { + target = c.cfg.Clients + } + if err := c.scaleTo(target, 0); err != nil { + log.Printf("scale warning: %v", err) + } + if err := waitContext(c.ctx, c.cfg.StepDuration); err != nil { + return err + } + if target == c.cfg.Clients { + break + } + } + case "spike": + if err := c.scaleTo(c.cfg.Clients, c.cfg.RampDuration); err != nil { + log.Printf("scale warning: %v", err) + } + return waitContext(c.ctx, c.cfg.Duration) + case "soak": + if c.cfg.StartClients > 0 { + if err := c.scaleTo(c.cfg.StartClients, 0); err != nil { + log.Printf("scale warning: %v", err) + } + } + if err := c.scaleTo(c.cfg.Clients, c.cfg.RampDuration); err != nil { + log.Printf("scale warning: %v", err) + } + return waitContext(c.ctx, c.cfg.Duration) + } + return nil +} + +func (c *controller) scaleTo(target int, ramp time.Duration) error { + c.mu.Lock() + current := len(c.clients) + c.target = target + c.mu.Unlock() + if target <= current { + return nil + } + delay := time.Duration(0) + if ramp > 0 { + delay = ramp / time.Duration(target-current) + } + var failures int + for attempt := 0; attempt < target-current; attempt++ { + if c.ctx.Err() != nil { + return c.ctx.Err() + } + c.mu.Lock() + c.nextOrdinal++ + ordinal := c.nextOrdinal + c.mu.Unlock() + client, err := startLoadClient(c.ctx, c.cfg, ordinal, c.iceServers, c.http) + if err != nil { + failures++ + log.Printf("client=%d start failed: %v", ordinal, err) + _, cancel := context.WithCancel(c.ctx) + failed := &loadClient{ordinal: ordinal, startedAt: time.Now(), pcState: "failed", iceState: "failed", cancel: cancel, http: c.http, baseURL: c.cfg.BaseURL} + failed.setError(err) + c.mu.Lock() + c.attempts = append(c.attempts, failed) + c.mu.Unlock() + } else { + c.mu.Lock() + c.clients = append(c.clients, client) + c.attempts = append(c.attempts, client) + c.mu.Unlock() + log.Printf("client=%d session=%s connected", ordinal, client.sessionID) + } + if delay > 0 && attempt+1 < target-current { + if err := waitContext(c.ctx, delay); err != nil { + return err + } + } + } + if failures > 0 { + return fmt.Errorf("%d of %d new clients failed", failures, target-current) + } + return nil +} + +func (c *controller) collectStats() { + ticker := time.NewTicker(c.cfg.StatsInterval) + defer ticker.Stop() + for { + select { + case <-c.ctx.Done(): + return + case <-ticker.C: + c.snapshot() + } + } +} + +func (c *controller) snapshot() { + c.mu.RLock() + clients := append([]*loadClient(nil), c.attempts...) + target := c.target + c.mu.RUnlock() + c.recorder.sample(target, clients) + log.Printf("stats %s", c.recorder.summary(clients)) +} + +func (c *controller) closeAll() { + c.mu.RLock() + clients := append([]*loadClient(nil), c.attempts...) + c.mu.RUnlock() + var wg sync.WaitGroup + for _, client := range clients { + wg.Add(1) + go func(client *loadClient) { defer wg.Done(); client.Close() }(client) + } + wg.Wait() +} + +func waitContext(ctx context.Context, duration time.Duration) error { + timer := time.NewTimer(duration) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} diff --git a/loadtest/pion-client/media.go b/loadtest/pion-client/media.go new file mode 100644 index 0000000..0649e54 --- /dev/null +++ b/loadtest/pion-client/media.go @@ -0,0 +1,84 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "sync/atomic" + "time" + + "github.com/pion/webrtc/v4/pkg/media" + "github.com/pion/webrtc/v4/pkg/media/ivfreader" +) + +type mediaCounters struct { + frames atomic.Uint64 + bytes atomic.Uint64 +} + +func streamIVF(ctx context.Context, path string, fpsOverride float64, track sampleWriter, counters *mediaCounters) error { + for { + file, reader, frameDuration, err := openIVF(path, fpsOverride) + if err != nil { + return err + } + err = playIVF(ctx, file, reader, frameDuration, track, counters) + _ = file.Close() + if errors.Is(err, io.EOF) { + continue + } + return err + } +} + +type sampleWriter interface { + WriteSample(media.Sample) error +} + +func openIVF(path string, fpsOverride float64) (*os.File, *ivfreader.IVFReader, time.Duration, error) { + file, err := os.Open(path) + if err != nil { + return nil, nil, 0, fmt.Errorf("open input: %w", err) + } + reader, header, err := ivfreader.NewWith(file) + if err != nil { + _ = file.Close() + return nil, nil, 0, fmt.Errorf("read IVF header: %w", err) + } + fps := fpsOverride + if fps == 0 { + if header.TimebaseNumerator == 0 { + _ = file.Close() + return nil, nil, 0, fmt.Errorf("IVF timebase numerator is zero; supply -fps") + } + fps = float64(header.TimebaseDenominator) / float64(header.TimebaseNumerator) + } + if fps <= 0 { + _ = file.Close() + return nil, nil, 0, fmt.Errorf("input FPS must be greater than zero") + } + return file, reader, time.Duration(float64(time.Second) / fps), nil +} + +func playIVF(ctx context.Context, file *os.File, reader *ivfreader.IVFReader, frameDuration time.Duration, track sampleWriter, counters *mediaCounters) error { + ticker := time.NewTicker(frameDuration) + defer ticker.Stop() + for { + frame, _, err := reader.ParseNextFrame() + if err != nil { + return err + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + if err := track.WriteSample(media.Sample{Data: frame, Duration: frameDuration}); err != nil { + return fmt.Errorf("write video sample: %w", err) + } + counters.frames.Add(1) + counters.bytes.Add(uint64(len(frame))) + } +} diff --git a/loadtest/pion-client/media_test.go b/loadtest/pion-client/media_test.go new file mode 100644 index 0000000..1660e9b --- /dev/null +++ b/loadtest/pion-client/media_test.go @@ -0,0 +1,75 @@ +package main + +import ( + "context" + "encoding/binary" + "errors" + "os" + "sync" + "testing" + + "github.com/pion/webrtc/v4/pkg/media" +) + +type cancelWriter struct { + mu sync.Mutex + frames int + cancel context.CancelFunc +} + +func (w *cancelWriter) WriteSample(media.Sample) error { + w.mu.Lock() + defer w.mu.Unlock() + w.frames++ + if w.frames == 3 { + w.cancel() + } + return nil +} + +func TestStreamIVFLoopsAtEOF(t *testing.T) { + path := writeTestIVF(t) + ctx, cancel := context.WithCancel(context.Background()) + writer := &cancelWriter{cancel: cancel} + err := streamIVF(ctx, path, 1000, writer, &mediaCounters{}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected cancellation, got %v", err) + } + writer.mu.Lock() + frames := writer.frames + writer.mu.Unlock() + if frames != 3 { + t.Fatalf("expected looped third frame, got %d", frames) + } +} + +func writeTestIVF(t *testing.T) string { + t.Helper() + path := t.TempDir() + "/input.ivf" + file, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + defer file.Close() + header := make([]byte, 32) + copy(header[0:4], "DKIF") + binary.LittleEndian.PutUint16(header[6:8], 32) + copy(header[8:12], "VP80") + binary.LittleEndian.PutUint16(header[12:14], 2) + binary.LittleEndian.PutUint16(header[14:16], 2) + binary.LittleEndian.PutUint32(header[16:20], 30) + binary.LittleEndian.PutUint32(header[20:24], 1) + binary.LittleEndian.PutUint32(header[24:28], 2) + if _, err := file.Write(header); err != nil { + t.Fatal(err) + } + for i := uint64(0); i < 2; i++ { + frameHeader := make([]byte, 12) + binary.LittleEndian.PutUint32(frameHeader[0:4], 1) + binary.LittleEndian.PutUint64(frameHeader[4:12], i) + if _, err := file.Write(append(frameHeader, byte(i))); err != nil { + t.Fatal(err) + } + } + return path +} diff --git a/loadtest/pion-client/results.go b/loadtest/pion-client/results.go new file mode 100644 index 0000000..fa4d7cd --- /dev/null +++ b/loadtest/pion-client/results.go @@ -0,0 +1,153 @@ +package main + +import ( + "encoding/csv" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + "sync" + "time" +) + +type sampleRow struct { + Timestamp time.Time `json:"timestamp"` + ElapsedSeconds float64 `json:"elapsed_seconds"` + TargetClients int `json:"target_clients"` + Client int `json:"client"` + SessionID string `json:"session_id"` + Connected bool `json:"connected"` + Failed bool `json:"failed"` + PCState string `json:"pc_state"` + ICEState string `json:"ice_state"` + SendFPS float64 `json:"send_fps"` + ReceiveFPS float64 `json:"receive_fps"` + SentFrames uint64 `json:"sent_frames"` + ReceivedFrames uint64 `json:"received_frames"` + ReceivedPackets uint64 `json:"received_packets"` + LostPackets uint64 `json:"lost_packets"` + PacketLossPct float64 `json:"packet_loss_pct"` + JitterMS float64 `json:"jitter_ms"` + RTTMS float64 `json:"rtt_ms"` + ReceivedBytes uint64 `json:"received_bytes"` + Error string `json:"error,omitempty"` +} + +type resultFile struct { + StartedAt time.Time `json:"started_at"` + FinishedAt time.Time `json:"finished_at"` + Scenario string `json:"scenario"` + Config config `json:"config"` + Samples []sampleRow `json:"samples"` +} + +type previousFrames struct { + sent, received uint64 + at time.Time +} + +type recorder struct { + mu sync.Mutex + started time.Time + cfg config + rows []sampleRow + previous map[int]previousFrames +} + +func newRecorder(cfg config) *recorder { + return &recorder{started: time.Now(), cfg: cfg, previous: make(map[int]previousFrames)} +} + +func (r *recorder) sample(target int, clients []*loadClient) { + r.mu.Lock() + defer r.mu.Unlock() + now := time.Now() + for _, client := range clients { + sentFrames, receivedFrames := client.sent.frames.Load(), client.received.frames.Load() + previous := r.previous[client.ordinal] + seconds := now.Sub(previous.at).Seconds() + var sendFPS, receiveFPS float64 + if !previous.at.IsZero() && seconds > 0 { + sendFPS = float64(sentFrames-previous.sent) / seconds + receiveFPS = float64(receivedFrames-previous.received) / seconds + } + r.previous[client.ordinal] = previousFrames{sent: sentFrames, received: receivedFrames, at: now} + packets, lost := client.received.packets.Load(), client.received.lost.Load() + var lossPct float64 + if packets+lost > 0 { + lossPct = float64(lost) * 100 / float64(packets+lost) + } + pcState, iceState, errText := client.states() + r.rows = append(r.rows, sampleRow{ + Timestamp: now, ElapsedSeconds: now.Sub(r.started).Seconds(), TargetClients: target, + Client: client.ordinal, SessionID: client.sessionID, Connected: client.connected.Load(), Failed: client.failed.Load(), + PCState: pcState, ICEState: iceState, SendFPS: sendFPS, ReceiveFPS: receiveFPS, + SentFrames: sentFrames, ReceivedFrames: receivedFrames, ReceivedPackets: packets, LostPackets: lost, + PacketLossPct: lossPct, JitterMS: float64(client.received.jitterMicros.Load()) / 1000, + RTTMS: float64(client.received.rttMicros.Load()) / 1000, ReceivedBytes: client.received.bytes.Load(), Error: errText, + }) + } +} + +func (r *recorder) write(prefix string) (string, string, error) { + r.mu.Lock() + defer r.mu.Unlock() + if err := os.MkdirAll(filepath.Dir(prefix), 0o755); err != nil && filepath.Dir(prefix) != "." { + return "", "", err + } + jsonPath, csvPath := prefix+".json", prefix+".csv" + jsonFile, err := os.Create(jsonPath) + if err != nil { + return "", "", err + } + encoder := json.NewEncoder(jsonFile) + encoder.SetIndent("", " ") + err = encoder.Encode(resultFile{StartedAt: r.started, FinishedAt: time.Now(), Scenario: r.cfg.Scenario, Config: r.cfg, Samples: r.rows}) + closeErr := jsonFile.Close() + if err != nil { + return "", "", err + } + if closeErr != nil { + return "", "", closeErr + } + + csvFile, err := os.Create(csvPath) + if err != nil { + return "", "", err + } + w := csv.NewWriter(csvFile) + _ = w.Write([]string{"timestamp", "elapsed_seconds", "target_clients", "client", "session_id", "connected", "failed", "pc_state", "ice_state", "send_fps", "receive_fps", "sent_frames", "received_frames", "received_packets", "lost_packets", "packet_loss_pct", "jitter_ms", "rtt_ms", "received_bytes", "error"}) + for _, row := range r.rows { + _ = w.Write([]string{row.Timestamp.Format(time.RFC3339Nano), f(row.ElapsedSeconds), strconv.Itoa(row.TargetClients), strconv.Itoa(row.Client), row.SessionID, strconv.FormatBool(row.Connected), strconv.FormatBool(row.Failed), row.PCState, row.ICEState, f(row.SendFPS), f(row.ReceiveFPS), u(row.SentFrames), u(row.ReceivedFrames), u(row.ReceivedPackets), u(row.LostPackets), f(row.PacketLossPct), f(row.JitterMS), f(row.RTTMS), u(row.ReceivedBytes), row.Error}) + } + w.Flush() + err = w.Error() + closeErr = csvFile.Close() + if err != nil { + return "", "", err + } + if closeErr != nil { + return "", "", closeErr + } + return jsonPath, csvPath, nil +} + +func f(value float64) string { return strconv.FormatFloat(value, 'f', 3, 64) } +func u(value uint64) string { return strconv.FormatUint(value, 10) } + +func (r *recorder) summary(clients []*loadClient) string { + connected, failed := 0, 0 + var sent, received uint64 + for _, client := range clients { + if client.connected.Load() { + connected++ + } + if client.failed.Load() { + failed++ + } + sent += client.sent.frames.Load() + received += client.received.frames.Load() + } + return fmt.Sprintf("attempts=%d connected=%d failed=%d sent_frames=%d received_frames=%d", len(clients), connected, failed, sent, received) +} diff --git a/loadtest/pion-client/signaling.go b/loadtest/pion-client/signaling.go new file mode 100644 index 0000000..4f38668 --- /dev/null +++ b/loadtest/pion-client/signaling.go @@ -0,0 +1,131 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + + "github.com/gorilla/websocket" + "github.com/pion/webrtc/v4" +) + +type signalMessage struct { + Type string `json:"type"` + SessionID string `json:"session_id"` + SDP string `json:"sdp,omitempty"` + Candidate *string `json:"candidate"` + SDPMid *string `json:"sdpMid,omitempty"` + SDPMLineIndex *uint16 `json:"sdpMLineIndex,omitempty"` + Error json.RawMessage `json:"error,omitempty"` +} + +type signalSocket struct { + conn *websocket.Conn + mu sync.Mutex +} + +func (s *signalSocket) write(v any) error { + s.mu.Lock() + defer s.mu.Unlock() + return s.conn.WriteJSON(v) +} + +func createSession(ctx context.Context, client *http.Client, baseURL string, ordinal int) (string, error) { + body, _ := json.Marshal(map[string]any{"metadata": map[string]string{ + "load_test": "pion", "client": fmt.Sprintf("%d", ordinal), + }}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/sessions", bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("create session: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return "", fmt.Errorf("create session: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(data))) + } + var result struct { + SessionID string `json:"session_id"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("decode session response: %w", err) + } + if result.SessionID == "" { + return "", fmt.Errorf("decode session response: session_id is empty") + } + return result.SessionID, nil +} + +func deleteSession(ctx context.Context, client *http.Client, baseURL, sessionID string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, baseURL+"/sessions/"+url.PathEscape(sessionID), nil) + if err != nil { + return err + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusNotFound { + return fmt.Errorf("delete session: HTTP %d", resp.StatusCode) + } + return nil +} + +func fetchICEServers(ctx context.Context, client *http.Client, baseURL string) ([]webrtc.ICEServer, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/webrtc/config", nil) + if err != nil { + return nil, err + } + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("get WebRTC config: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("get WebRTC config: HTTP %d", resp.StatusCode) + } + var payload struct { + ICEServers []struct { + URLs []string `json:"urls"` + Username string `json:"username"` + Credential string `json:"credential"` + } `json:"iceServers"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, err + } + servers := make([]webrtc.ICEServer, 0, len(payload.ICEServers)) + for _, server := range payload.ICEServers { + servers = append(servers, webrtc.ICEServer{URLs: server.URLs, Username: server.Username, Credential: server.Credential}) + } + return servers, nil +} + +func websocketURL(baseURL string) (string, error) { + u, err := url.Parse(baseURL) + if err != nil { + return "", err + } + switch u.Scheme { + case "http": + u.Scheme = "ws" + case "https": + u.Scheme = "wss" + default: + return "", fmt.Errorf("base URL must use http or https") + } + u.Path = "/signaling" + u.RawQuery = "" + return u.String(), nil +} diff --git a/loadtest/videos/README.md b/loadtest/videos/README.md new file mode 100644 index 0000000..f0218cf --- /dev/null +++ b/loadtest/videos/README.md @@ -0,0 +1,13 @@ +# Local test videos + +Video assets in this directory are intentionally ignored by Git. Keep the +source MP4 as `input.mp4` and the Pion-ready VP8 file as `720p30.ivf`. + +Generate the load-test input with: + +```bash +ffmpeg -i input.mp4 -an \ + -vf "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2,fps=30" \ + -c:v libvpx -deadline good -cpu-used 4 -b:v 2M -maxrate 2M -bufsize 4M \ + 720p30.ivf +``` diff --git a/observability/grafana/dashboards/innolive.json b/observability/grafana/dashboards/innolive.json new file mode 100644 index 0000000..55aab69 --- /dev/null +++ b/observability/grafana/dashboards/innolive.json @@ -0,0 +1,39 @@ +{ + "title": "InnoLive Load Test", + "uid": "innolive-load-test", + "schemaVersion": 42, + "refresh": "5s", + "time": {"from": "now-30m", "to": "now"}, + "panels": [ + { + "type": "stat", + "title": "Active sessions", + "gridPos": {"h": 6, "w": 6, "x": 0, "y": 0}, + "targets": [{"expr": "innolive_active_sessions", "refId": "A"}] + }, + { + "type": "timeseries", + "title": "Processed FPS by mode", + "gridPos": {"h": 8, "w": 9, "x": 6, "y": 0}, + "targets": [{"expr": "sum by (mode) (rate(innolive_frame_processed_total[1m]))", "refId": "A"}] + }, + { + "type": "timeseries", + "title": "Frame processing p95", + "gridPos": {"h": 8, "w": 9, "x": 15, "y": 0}, + "targets": [{"expr": "histogram_quantile(0.95, sum by (le, mode) (rate(innolive_frame_processing_duration_seconds_bucket[5m])))", "refId": "A"}] + }, + { + "type": "timeseries", + "title": "Dropped frames", + "gridPos": {"h": 8, "w": 12, "x": 0, "y": 8}, + "targets": [{"expr": "sum by (mode) (rate(innolive_frame_dropped_total[1m]))", "refId": "A"}] + }, + { + "type": "timeseries", + "title": "AI queue depth", + "gridPos": {"h": 8, "w": 12, "x": 12, "y": 8}, + "targets": [{"expr": "innolive_processing_queue_size", "refId": "A"}] + } + ] +} diff --git a/observability/grafana/provisioning/dashboards/dashboards.yml b/observability/grafana/provisioning/dashboards/dashboards.yml new file mode 100644 index 0000000..802e0bf --- /dev/null +++ b/observability/grafana/provisioning/dashboards/dashboards.yml @@ -0,0 +1,7 @@ +apiVersion: 1 + +providers: + - name: InnoLive + type: file + options: + path: /var/lib/grafana/dashboards diff --git a/observability/grafana/provisioning/datasources/prometheus.yml b/observability/grafana/provisioning/datasources/prometheus.yml new file mode 100644 index 0000000..86fd346 --- /dev/null +++ b/observability/grafana/provisioning/datasources/prometheus.yml @@ -0,0 +1,8 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true diff --git a/observability/prometheus.yml b/observability/prometheus.yml new file mode 100644 index 0000000..680442a --- /dev/null +++ b/observability/prometheus.yml @@ -0,0 +1,8 @@ +global: + scrape_interval: 5s + evaluation_interval: 5s + +scrape_configs: + - job_name: innolive + static_configs: + - targets: ["host.docker.internal:8000"] diff --git a/requirements.txt b/requirements.txt index cd62e22..67f0a7f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,6 +8,7 @@ av==16.1.0 python-multipart==0.0.31 httpx==0.28.1 python-dotenv==1.2.2 +prometheus-client==0.23.1 numpy>=1.24 opencv-python-headless>=4.8 ultralytics>=8.0 diff --git a/test-docs/test-backlog.md b/test-docs/test-backlog.md new file mode 100644 index 0000000..caeada4 --- /dev/null +++ b/test-docs/test-backlog.md @@ -0,0 +1,86 @@ +## 남은 작업 + +우선순위 순입니다. + +1. 실제 영상 부하용 Go/Pion 클라이언트 + +현재 k6는 REST 세션 부하만 만듭니다. 최대 동시 방송 수를 측정하려면 다음 기능을 가진 클라이언트가 필요합니다. + +- 영상 파일 반복 송신 +- 처리 영상 수신 +- 다중 PeerConnection +- Offer/Answer와 ICE 교환 +- 수신 FPS, RTT, jitter, packet loss 기록 +- 계단식·스파이크·지속 부하 +- CSV/JSON 결과 저장 + +이것이 본 성능 시험을 시작하기 위한 가장 큰 잔여 작업입니다. + +2. End-to-End 지연 측정 + +아직 서버 내부 프레임 처리 시간만 측정합니다. 송신부터 반환 영상 수신까지 측정하려면 다음 중 하나가 필요합니다. + +- 영상에 프레임 번호·타임코드 삽입 +- RTP header extension으로 `frame_id`와 timestamp 전달 + +첫 구현은 타임코드 영상 방식이 현실적입니다. + +3. 단계별 처리 지연 + +현재는 프레임 전체 처리 시간을 측정합니다. 플랜의 병목 분석을 위해 다음을 분리해야 합니다. + +- decode +- AI detection +- identity comparison +- blur +- encode + +4. Docker 설치 및 모니터링 스택 검증 + +Compose 파일은 추가했지만 현재 PC에는 Docker가 설치되어 있지 않아 실제 컨테이너 기동 검증은 아직 못 했습니다. Docker Desktop 설치 후: + +```bash +docker compose -f docker-compose.test.yml up -d +``` + +접속 주소: + +```text +Prometheus: http://127.0.0.1:9090 +Grafana: http://127.0.0.1:3000 +계정: admin / admin +``` + +5. 시스템·GPU 자원 메트릭 + +애플리케이션 메트릭 외에 다음 수집기가 필요합니다. + +- CPU/RAM: Node Exporter +- 컨테이너: cAdvisor +- GPU/VRAM: DCGM Exporter + +WSL2에서 DCGM Exporter가 제한되면 `nvidia-smi` 주기 수집기를 대신 사용할 수 있습니다. + +6. 고정 테스트 영상 + +720p 30FPS 기준으로 적어도 다음 세 종류가 필요합니다. + +- 얼굴 1명, 움직임 적음 +- 얼굴 4명, 움직임 보통 +- 얼굴 다수, 움직임 많음 + +7. 네트워크 장애 자동화 + +`tc netem`은 설치됐지만 시나리오 자동화 스크립트는 아직 없습니다. + +- 지연 50/100/150ms +- jitter +- 손실 1/3/5% +- 3초 단절 +- 테스트 종료 시 자동 복구 + +8. Go 서버 + +현재 저장소는 Python 서버뿐입니다. Python 기준선 시험은 가능하지만 최종 언어 비교는 동일 API와 AI 의미를 갖는 Go 서버 구현 후 가능합니다. + +현재 상태에서는 단일 브라우저 GPU 기능 시험과 k6 제어부하 시험까지 가능하며, 다음 구현 목표는 Go/Pion 실제 영상 부하 클라이언트입니다. \ No newline at end of file diff --git a/test-docs/test-plan.md b/test-docs/test-plan.md new file mode 100644 index 0000000..af22f8e --- /dev/null +++ b/test-docs/test-plan.md @@ -0,0 +1,637 @@ +## 결론 + +두 테스트를 따로 진행하기보다 **동일한 WebRTC 부하 생성기를 이용해 Python 서버와 Go 서버를 번갈아 테스트하는 통합 방식**이 가장 적절해. + +InnoLive는 클라이언트가 원본 영상을 WebRTC로 서버와 Vision AI에 보내고, 비식별화된 영상을 다시 받아 스트리밍 플랫폼으로 송출하는 구조다. 따라서 여기서 말하는 “수용 가능한 유저 수”는 일반적인 회원 수나 시청자 수가 아니라 **동시에 비식별화 방송을 실행하는 활성 스트림 수**로 정의해야 한다. 스트리밍 플랫폼의 시청자 트래픽은 InnoLive 서버가 직접 처리하지 않기 때문이다. + +즉, 최종적으로 다음 결과를 얻는 테스트를 설계하면 된다. + +> 동일 서버 사양에서 720p 30FPS 방송을 Python 서버는 최대 몇 개, Go 서버는 최대 몇 개까지 안정적으로 처리하는가? +> + +--- + +# 1. 권장 테스트 전체 구조 + +``` +┌──────────────────────────────────────┐ +│ 부하 발생 서버 │ +│ │ +│ k6 │ +│ - 로그인/API 호출 │ +│ - WebSocket 시그널링 │ +│ │ +│ Go + Pion WebRTC 가상 클라이언트 │ +│ - 테스트 영상 반복 송신 │ +│ - 처리 영상 수신 │ +│ - 지연 시간/FPS/손실 측정 │ +└──────────────────┬───────────────────┘ + │ WebRTC + ▼ +┌──────────────────────────────────────┐ +│ 테스트 대상 │ +│ │ +│ A. Python 서버 │ +│ B. Go 서버 │ +│ │ +│ 동일한 AI 모델 / 동일한 Vision AI 서버 │ +└──────────────────┬───────────────────┘ + │ + ▼ +┌──────────────────────────────────────┐ +│ Prometheus + Grafana │ +│ CPU / RAM / GPU / Network / FPS │ +│ 처리 지연 / 세션 수 / 오류율 │ +└──────────────────────────────────────┘ +``` + +부하 발생기는 반드시 테스트 대상 서버와 **다른 컴퓨터나 인스턴스**에서 실행하는 게 좋다. 한 컴퓨터에서 부하 생성과 서버를 동시에 실행하면 부하 생성기 자체가 CPU나 네트워크를 사용해서 결과가 왜곡될 수 있다. + +--- + +# 2. 사용할 도구 + +## 2.1 실제 영상 부하: Go + Pion WebRTC + +가장 중요한 도구다. + +일반적인 HTTP 부하 테스트 도구는 REST API 요청은 많이 만들 수 있지만, 실제 WebRTC 영상 트랙을 지속적으로 송수신하지는 못한다. 따라서 InnoLive에서는 **가상의 방송 클라이언트**를 직접 만들어야 한다. + +추천 구성: + +- Go +- Pion WebRTC +- 고정된 MP4 테스트 영상 +- 한 프로세스에서 여러 PeerConnection 생성 +- 각 PeerConnection이 영상 송신 및 처리 결과 수신 +- 세션마다 WebRTC 통계와 영상 처리 지연 기록 + +Pion의 `webrtc-bench` 프로젝트는 영상 파일을 WebRTC로 재생하면서 PeerConnection 수, CPU 사용량, 연결 단계별 지연 등을 측정하는 구조이므로 부하 생성기 구현의 출발점으로 활용하기 좋다. (GitHub) + +부하 생성기는 다음처럼 동작하면 된다. + +``` +1번 가상 클라이언트 연결 +2번 가상 클라이언트 연결 +3번 가상 클라이언트 연결 +... +N번 가상 클라이언트 연결 + +각 클라이언트: +- 같은 테스트 영상 반복 송신 +- 처리된 영상 수신 +- 수신 FPS 기록 +- 프레임 누락 기록 +- RTT, jitter, packet loss 기록 +- 연결 종료 여부 기록 +``` + +## 2.2 API와 시그널링: k6 + +k6는 다음 영역에 사용하면 된다. + +- 로그인 +- 방송 세션 생성 및 종료 +- Offer/Answer 교환 +- ICE candidate 전달 +- WebSocket 연결 +- 얼굴 등록 API +- 다수 사용자의 동시 재연결 + +현재 k6는 WebSocket 부하 테스트와 WebSocket 관련 연결·메시지 메트릭을 제공한다. 다만 **실제 RTP 영상 부하는 Pion 클라이언트가 담당**하고, k6는 제어 영역을 담당하도록 분리하는 게 좋다. (Grafana Labs) + +## 2.3 고정된 영상 입력: FFmpeg + +FFmpeg로 동일한 영상을 동일한 조건으로 준비한다. + +예시 테스트 프로필: + +| 프로필 | 해상도 | FPS | 얼굴 수 | 움직임 | +| --- | --- | --- | --- | --- | +| A | 1280×720 | 30 | 1명 | 적음 | +| B | 1280×720 | 30 | 4명 | 보통 | +| C | 1920×1080 | 30 | 4명 | 보통 | +| D | 1920×1080 | 30 | 10명 이상 | 많음 | + +얼굴 탐지·비식별화 서비스는 단순히 해상도만 같다고 부하가 동일하지 않다. 프레임 내 얼굴 수, 얼굴 크기, 움직임, 등록 인물 비교 여부에 따라 AI 연산량이 달라질 수 있으므로 테스트 영상도 여러 종류가 필요하다. + +FFmpeg는 해상도, FPS, 비트레이트, 코덱을 고정하고 테스트 영상을 반복 재생하는 데 사용할 수 있다. 필요하다면 FFmpeg의 필터를 이용해 출력 영상의 품질 비교도 할 수 있다. (FFmpeg) + +## 2.4 서버 모니터링: Prometheus + Grafana + +서버 내부에 다음 메트릭을 직접 추가하는 게 좋다. + +``` +innolive_active_sessions +innolive_connection_total +innolive_connection_failures_total + +innolive_frame_received_total +innolive_frame_processed_total +innolive_frame_dropped_total + +innolive_decode_duration_seconds +innolive_ai_duration_seconds +innolive_blur_duration_seconds +innolive_encode_duration_seconds +innolive_end_to_end_duration_seconds + +innolive_processing_queue_size +innolive_reconnect_total +``` + +Prometheus는 애플리케이션에 언어별 클라이언트 라이브러리를 붙여 내부 메트릭을 노출하는 방식을 지원한다. 호스트 자원은 Node Exporter, 컨테이너 자원은 cAdvisor, NVIDIA GPU를 사용한다면 DCGM Exporter로 수집할 수 있다. (Prometheus) + +Grafana 대시보드에는 최소한 다음 그래프가 있어야 한다. + +- 활성 WebRTC 세션 수 +- CPU 사용률 +- 프로세스 메모리 +- 세션당 CPU 및 메모리 +- GPU 사용률과 GPU 메모리 +- 초당 처리 프레임 +- 프레임 처리 지연 p50, p95, p99 +- WebRTC 패킷 손실률 +- 처리 큐 크기 +- 연결 실패 및 재연결 횟수 + +## 2.5 병목 분석 + +Python: + +- `py-spy` +- `cProfile` +- `pstats` + +Go: + +- `net/http/pprof` +- `go tool pprof` +- Go benchmark + +Python의 `cProfile`은 함수 호출 횟수와 누적 실행 시간을 확인할 수 있고, `py-spy`는 실행 중인 프로세스에 붙어서 비교적 낮은 부하로 샘플링 프로파일을 만들 수 있다. Go는 표준 `pprof` 엔드포인트를 통해 CPU, heap, goroutine, allocation 프로파일을 수집할 수 있다. (Python documentation) + +## 2.6 네트워크 환경 재현: Linux `tc netem` + +WebRTC는 실제 네트워크 상태에 따라 결과가 크게 달라지므로 정상 네트워크만 테스트하면 부족하다. + +`tc netem`을 사용하면 다음을 인위적으로 발생시킬 수 있다. + +- 네트워크 지연 +- jitter +- 패킷 손실 +- 패킷 중복 +- 패킷 순서 변경 + +`netem`은 실제 네트워크 특성을 시험하기 위해 delay, loss, duplication, corruption 등을 적용하는 Linux 네트워크 에뮬레이션 기능이다. (man7.org) + +예시 조건: + +``` +정상 환경: +delay 5ms / loss 0% + +일반 무선 환경: +delay 30ms ± 10ms / loss 0.5% + +불안정한 모바일 환경: +delay 80ms ± 30ms / loss 2% + +매우 불안정한 환경: +delay 150ms ± 50ms / loss 5% +``` + +--- + +# 3. 반드시 수집해야 할 성능 지표 + +## 서버 처리 지표 + +| 지표 | 의미 | +| --- | --- | +| 최대 동시 스트림 | SLO를 만족하면서 유지 가능한 방송 수 | +| 처리 FPS | 서버가 실제로 처리한 초당 프레임 | +| 프레임 처리 지연 | 디코딩부터 비식별화 및 인코딩까지 | +| End-to-End 지연 | 클라이언트 송신부터 처리 영상 수신까지 | +| 프레임 드롭률 | 입력됐지만 출력되지 못한 프레임 비율 | +| CPU 사용률 | 전체 및 세션당 사용량 | +| 메모리 사용량 | RSS, heap, 세션당 메모리 | +| GPU 사용률 | 연산 사용률과 VRAM | +| 큐 길이 | 처리 대기 중인 프레임 수 | +| 오류율 | 연결 실패, 추론 실패, 인코딩 실패 | + +## WebRTC 지표 + +- RTT +- jitter +- packetsLost +- framesDropped +- framesEncoded +- framesDecoded +- keyFramesEncoded +- outbound/inbound bitrate +- connection setup time +- ICE 연결 성공률 +- TURN 사용 비율 + +WebRTC의 `getStats()` 표준은 RTP 스트림, 코덱, 전송, ICE, 송수신 영상 등에 관한 통계를 제공하도록 정의되어 있다. 부하 클라이언트에서 해당 통계를 주기적으로 수집하면 된다. (W3C) + +--- + +# 4. End-to-End 지연 측정 방법 + +단순히 서버 함수 실행 시간만 재면 실제 사용자가 체감하는 지연을 알 수 없다. + +가장 정확한 방법은 각 프레임에 다음 정보를 부여하는 것이다. + +``` +frame_id +send_timestamp +session_id +``` + +가능한 방식은 두 가지다. + +### 방식 A: RTP Header Extension + +송신 프레임의 RTP 확장 헤더에 프레임 ID와 송신 시간을 넣고, 처리된 영상 반환 시 해당 값을 유지하거나 연결한다. + +장점: + +- 이미지 내용에 영향을 주지 않음 +- 자동화하기 좋음 +- 매우 정확함 + +단점: + +- 서버 파이프라인에서 메타데이터를 유지하도록 구현해야 함 + +### 방식 B: 영상 구석에 시간 코드 삽입 + +FFmpeg로 영상 한쪽 구석에 프레임 번호나 QR 형태의 시간 코드를 넣고, 반환 영상에서 읽는다. + +장점: + +- 서버 코드를 크게 수정하지 않아도 됨 +- 실제 영상 파이프라인 전체 지연 측정 가능 + +단점: + +- 시간 코드 인식 코드가 필요함 +- 블러 영역과 겹치지 않도록 해야 함 + +서로 다른 컴퓨터에서 시간을 비교할 때는 시스템 시계 오차가 들어갈 수 있으므로, 가능하면 **송신과 수신을 같은 부하 클라이언트 프로세스에서 측정**하는 것이 좋다. + +--- + +# 5. 테스트 시나리오 + +## 1단계: 단일 세션 기준선 + +사용자 1명만 연결한다. + +측정: + +- 기본 End-to-End 지연 +- 평균 CPU +- 평균 RAM +- AI 추론 시간 +- 인코딩 및 디코딩 시간 +- 정상 FPS +- 비식별화 정상 여부 + +이 결과가 Python과 Go 서버 비교의 기준선이 된다. + +--- + +## 2단계: 계단식 부하 테스트 + +다음과 같이 동시 스트림 수를 증가시킨다. + +``` +1개 → 5분 유지 +2개 → 5분 유지 +4개 → 5분 유지 +8개 → 5분 유지 +12개 → 5분 유지 +16개 → 5분 유지 +20개 → 5분 유지 +... +``` + +한계에 가까워지면 증가 단위를 줄인다. + +``` +20 → 22 → 24 → 26 → 28 +``` + +각 단계에서 다음 중 하나라도 발생하면 한계에 도달한 것으로 본다. + +- p95 지연이 설정한 기준 초과 +- 처리 FPS가 목표 이하로 감소 +- 프레임 드롭률이 기준 초과 +- 연결 실패 발생 +- 처리 큐가 계속 증가 +- CPU 또는 GPU가 장시간 포화 +- 메모리가 지속적으로 증가 +- 영상이 끊기거나 정지 + +--- + +## 3단계: 지속 부하 테스트 + +최대 처리량 테스트만 하면 메모리 누수나 장시간 연결 문제를 발견하기 어렵다. + +계단식 테스트에서 확인한 최대 안정 세션의 약 70~80% 수준을 이용해 다음 테스트를 한다. + +``` +동시 스트림 20개 +지속 시간 1~2시간 +``` + +확인 사항: + +- 메모리가 계속 증가하지 않는가 +- goroutine/thread가 누적되지 않는가 +- WebRTC 연결이 점차 끊기지 않는가 +- 처리 지연이 시간이 지나며 증가하지 않는가 +- GPU 메모리가 반환되는가 +- 프레임 처리 큐가 누적되지 않는가 + +--- + +## 4단계: 스파이크 테스트 + +모든 클라이언트가 동시에 방송을 시작하는 상황을 테스트한다. + +``` +0개 → 20개 즉시 연결 +20개 → 전체 연결 종료 +0개 → 20개 즉시 재연결 +``` + +이 테스트에서는 다음을 본다. + +- Offer/Answer 처리 병목 +- ICE 연결 실패 +- 서버 초기화 경쟁 +- AI 모델 요청 폭증 +- 소켓 및 파일 디스크립터 부족 +- 재연결 시 리소스 정리 실패 + +--- + +## 5단계: 네트워크 장애 테스트 + +각 동시 사용자 단계에서 다음 조건을 추가한다. + +``` +패킷 손실 1% +패킷 손실 3% +지연 50ms +지연 100ms + jitter 30ms +일시적인 3초 네트워크 단절 +``` + +WebRTC 연결이 복구되는지, 복구 후 영상과 서버 자원이 정상으로 돌아오는지 확인한다. + +--- + +# 6. Python과 Go의 공정한 비교 방법 + +언어만 다르게 하고 나머지는 모두 같아야 한다. + +## 고정해야 할 조건 + +- 같은 서버 장비 +- 같은 CPU 코어 수 +- 같은 메모리 제한 +- 같은 OS +- 같은 Docker 제한 +- 같은 WebRTC 코덱 +- 같은 해상도와 FPS +- 같은 비트레이트 +- 같은 AI 모델 +- 같은 AI 런타임 +- 같은 Vision AI 서버 +- 같은 테스트 영상 +- 같은 네트워크 조건 +- 같은 로그 레벨 +- 같은 부하 발생기 + +Python과 Go 서버를 동시에 실행하지 말고 다음처럼 번갈아 실행하는 게 좋다. + +``` +Python 1차 +Go 1차 +Python 2차 +Go 2차 +Python 3차 +Go 3차 +... +``` + +CPU 온도, 캐시, 백그라운드 프로세스 등의 영향을 줄이기 위해 각 조건을 최소 5회, 가능하면 10회 반복한다. + +--- + +# 7. AI 병목과 서버 언어 병목을 분리해야 함 + +가장 중요한 부분이다. + +전체 파이프라인에서 AI 추론이 대부분의 시간을 차지한다면 Python을 Go로 바꿔도 실제 End-to-End 속도는 거의 개선되지 않을 수 있다. + +따라서 다음 세 가지 모드를 각각 테스트해야 한다. + +## 모드 A: AI를 우회한 서버 테스트 + +``` +WebRTC 수신 +→ 영상 그대로 반환 +``` + +측정 대상: + +- WebRTC 처리 +- RTP 패킷 처리 +- 프레임 복사 +- 동시 연결 관리 +- 언어 런타임 차이 + +여기서는 Python과 Go 서버 자체의 차이가 가장 잘 드러난다. + +## 모드 B: 가짜 AI 지연 테스트 + +``` +WebRTC 수신 +→ 일정 시간 대기 +→ 영상 반환 +``` + +예를 들어 AI 추론 대신 항상 20ms를 기다린다. + +측정 대상: + +- 동일한 외부 처리 시간이 있을 때 서버의 동시성 처리 차이 +- Python 이벤트 루프와 Go goroutine 구조의 차이 + +## 모드 C: 실제 AI 포함 + +``` +WebRTC 수신 +→ 실제 얼굴 탐지 +→ 등록 인물 판별 +→ 블러 +→ 인코딩 +→ 반환 +``` + +이것이 실제 사용자 환경의 최종 성능이다. + +결과는 다음처럼 해석할 수 있다. + +``` +A에서는 Go가 크게 빠름 +C에서는 차이가 거의 없음 +→ AI 또는 인코딩이 병목 + +A와 C 모두 Go가 크게 빠름 +→ Python 서버의 패킷/프레임 처리 또는 동시성 구조가 병목 + +두 환경 모두 CPU는 여유롭지만 GPU가 100% +→ 서버 언어보다 AI 배치 처리 또는 GPU 확장이 우선 +``` + +--- + +# 8. 권장 성능 합격 기준 + +수치는 현재 단일 세션 측정 결과에 맞게 조정해야 하지만, 초기 기준은 다음처럼 잡을 수 있다. + +| 항목 | 예시 기준 | +| --- | --- | +| 연결 성공률 | 99% 이상 | +| 처리 FPS | 목표 FPS의 95% 이상 | +| 프레임 드롭률 | 1% 미만 | +| 서버 오류율 | 0.1% 미만 | +| End-to-End 지연 | 제품 목표 이하 | +| 메모리 증가 | 지속 테스트 중 안정화 | +| 처리 큐 | 지속적으로 증가하지 않음 | +| 비식별화 정확도 | 기존 Python보다 감소하지 않음 | + +InnoLive가 “초저지연”을 핵심 가치로 내세우고 있으므로, 단순히 서버가 죽지 않는지를 기준으로 하면 안 된다. 지연과 프레임 드롭, 비식별화 정상 동작을 모두 만족해야 수용 가능한 세션으로 계산해야 한다. + +최종 수용량은 다음처럼 정의하는 게 좋다. + +> 30분 이상 연결을 유지하면서 p95 지연, FPS, 프레임 드롭률, 오류율 기준을 모두 만족하는 최대 동시 방송 세션 수 +> + +운영 시에는 실험에서 나온 최대치의 100%를 사용하지 않고 약 20~30%의 여유 용량을 두는 편이 안전하다. + +--- + +# 9. 최종 결과표 형식 + +## 고정 사용자 수 성능 + +| 항목 | Python | Go | 차이 | +| --- | --- | --- | --- | +| 동시 스트림 | 10 | 10 | 동일 | +| p50 지연 | | | | +| p95 지연 | | | | +| p99 지연 | | | | +| 평균 처리 FPS | | | | +| 프레임 드롭률 | | | | +| CPU 사용률 | | | | +| 메모리 사용량 | | | | +| 세션당 메모리 | | | | +| GPU 사용률 | | | | + +## 최대 수용량 비교 + +| 항목 | Python | Go | +| --- | --- | --- | +| 최대 연결 가능 세션 | | | +| 최대 안정 세션 | | | +| 운영 권장 세션 | | | +| 세션당 CPU | | | +| 세션당 메모리 | | | +| 한계 도달 원인 | | | + +결과 계산은 다음처럼 하면 된다. + +``` +처리량 향상률 = +(Go 최대 세션 - Python 최대 세션) +÷ Python 최대 세션 × 100 + +p95 지연 개선 배수 = +Python p95 지연 ÷ Go p95 지연 + +메모리 절감률 = +(Python 메모리 - Go 메모리) +÷ Python 메모리 × 100 +``` + +예를 들어 결과가 다음과 같다면: + +``` +Python 최대 안정 세션: 12개 +Go 최대 안정 세션: 20개 +``` + +``` +처리량 향상률 += (20 - 12) / 12 × 100 += 약 66.7% +``` + +--- + +# 10. 실제 진행 순서 + +1. **성능 기준 정의** + - 720p 30FPS + - 코덱 및 비트레이트 고정 + - 허용 지연과 드롭률 결정 +2. **테스트 영상 제작** + - 얼굴 수와 움직임이 다른 영상 3~4개 준비 +3. **서버 메트릭 추가** + - 프레임 단계별 처리 시간 + - 활성 세션 + - 큐 길이 + - 실패 횟수 +4. **Pion 부하 클라이언트 개발** + - 영상 송신 + - 처리 영상 수신 + - WebRTC 통계 수집 + - 동시 세션 수 조절 +5. **Python 기준 테스트** + - 단일 세션 + - 계단식 부하 + - 지속 부하 + - 스파이크 및 네트워크 장애 +6. **Go 서버를 같은 인터페이스로 구현** +7. **동일한 부하 스크립트로 Go 테스트** +8. **AI 우회·가짜 AI·실제 AI 결과 비교** +9. **병목 프로파일링** + - Python: py-spy + - Go: pprof +10. **최대 안정 세션과 비용 산출** + +핵심 도구 구성은 다음으로 정리할 수 있다. + +``` +API·시그널링 부하: k6 +실제 WebRTC 영상 부하: Go + Pion +영상 소스 생성: FFmpeg +메트릭 수집: Prometheus +시각화: Grafana +컨테이너 측정: cAdvisor +GPU 측정: DCGM Exporter +네트워크 장애: tc netem +Python 분석: py-spy / cProfile +Go 분석: pprof +``` + +이 방식이면 **“현재 서버가 몇 명을 처리하는가”와 “Go로 전환했을 때 실제로 몇 배 개선되는가”를 동일한 실험에서 함께 증명**할 수 있다. \ No newline at end of file diff --git a/test-docs/test-result.md b/test-docs/test-result.md new file mode 100644 index 0000000..18dd53c --- /dev/null +++ b/test-docs/test-result.md @@ -0,0 +1,27 @@ +# 9. 최종 결과표 형식 + +## 고정 사용자 수 성능 + +| 항목 | Python | Go | 차이 | +| --- | --- | --- | --- | +| 동시 스트림 | 10 | 10 | 동일 | +| p50 지연 | | | | +| p95 지연 | | | | +| p99 지연 | | | | +| 평균 처리 FPS | | | | +| 프레임 드롭률 | | | | +| CPU 사용률 | | | | +| 메모리 사용량 | | | | +| 세션당 메모리 | | | | +| GPU 사용률 | | | | + +## 최대 수용량 비교 + +| 항목 | Python | Go | +| --- | --- | --- | +| 최대 연결 가능 세션 | | | +| 최대 안정 세션 | | | +| 운영 권장 세션 | | | +| 세션당 CPU | | | +| 세션당 메모리 | | | +| 한계 도달 원인 | | | \ No newline at end of file diff --git a/test-docs/test-runbook.md b/test-docs/test-runbook.md new file mode 100644 index 0000000..e1cd1fb --- /dev/null +++ b/test-docs/test-runbook.md @@ -0,0 +1,63 @@ +# Local load-test runbook + +## Server modes + +Use the same command and change only `AI_PRIVACY_MODE`: + +- `bypass`: WebRTC frame forwarding without AI. +- `fixed_delay`: forwarding after `AI_PRIVACY_FIXED_DELAY_MS`. +- `real`: the production privacy pipeline. + +Example: + +```bash +AI_PRIVACY_ENABLED=true \ +AI_PRIVACY_MODE=real \ +AI_PRIVACY_DEVICE=cuda:0 \ +AI_PRIVACY_REQUIRE_GPU=true \ +AI_PRIVACY_USE_HALF=true \ +python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 +``` + +## Metrics + +Prometheus metrics are exposed at `GET /metrics`. Start the supplied monitoring +stack with: + +```bash +docker compose -f docker-compose.test.yml up -d +``` + +- Prometheus: +- Grafana: (`admin` / `admin`) + +## Control-plane load + +The k6 script measures REST session lifecycle load only. It does not generate +RTP video traffic. + +```bash +BASE_URL=http://127.0.0.1:8000 VUS=10 HOLD=2m SESSION_SECONDS=5 \ +k6 run loadtest/k6/sessions.js +``` + +Do not use the k6 result as the maximum video-stream capacity. + +## Real WebRTC media load + +The Go/Pion client in `loadtest/pion-client` creates real media sessions. It +loops a VP8/IVF video, receives the processed track, drives step/spike/soak load, +and saves FPS, packet loss, jitter, RTT, connection state, and errors as JSON +and CSV. + +```bash +cd loadtest/pion-client +go build -o pion-load . +./pion-load -base-url http://127.0.0.1:8000 \ + -input ../videos/720p30.ivf \ + -scenario step -clients 20 -step-size 2 -step-duration 2m \ + -output results/step-real +``` + +See `loadtest/pion-client/README.md` for input conversion, all scenarios, and +result-field details. diff --git a/tests/test_api_sessions.py b/tests/test_api_sessions.py index bd3a587..bbbcf67 100644 --- a/tests/test_api_sessions.py +++ b/tests/test_api_sessions.py @@ -84,6 +84,15 @@ async def test_health_returns_ai_runtime_metrics(client): assert isinstance(body["ai_runtimes"], list) +async def test_metrics_exposes_prometheus_series(client): + response = await client.get("/metrics") + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/plain") + assert "innolive_active_sessions" in response.text + assert "innolive_frame_processing_duration_seconds" in response.text + + # --------------------------------------------------------------------------- # POST /sessions # --------------------------------------------------------------------------- diff --git a/tests/test_wrap_with_ai.py b/tests/test_wrap_with_ai.py index 5a4e742..6b3afca 100644 --- a/tests/test_wrap_with_ai.py +++ b/tests/test_wrap_with_ai.py @@ -67,3 +67,25 @@ def test_protected_track_has_correct_session_id(mock_track, mock_privacy_filter) result = _wrap_with_ai(mock_track, "session-abc") assert result._session_id == "session-abc" + + +@pytest.mark.parametrize("mode", ["bypass", "fixed_delay"]) +def test_benchmark_modes_return_instrumented_track( + mock_track, + settings_ai_enabled, + monkeypatch, + mode, +): + from app.core.config import get_settings + from app.services.ai.track import BenchmarkVideoTrack + from app.services.sessions.manager import _wrap_with_ai + + monkeypatch.setenv("AI_PRIVACY_MODE", mode) + monkeypatch.setenv("AI_PRIVACY_FIXED_DELAY_MS", "25") + get_settings.cache_clear() + + result = _wrap_with_ai(mock_track, "session-benchmark") + + assert isinstance(result, BenchmarkVideoTrack) + assert result._mode == mode + assert result._delay_seconds == (0.025 if mode == "fixed_delay" else 0.0) From 21db02747900fd776d5e3fb4e567253bd3b6ef3e Mon Sep 17 00:00:00 2001 From: Finefinee Date: Sun, 12 Jul 2026 16:14:52 +0900 Subject: [PATCH 02/11] docs: update test backlog --- test-docs/test-backlog.md | 84 ++++++++++++++++++++------------------- 1 file changed, 43 insertions(+), 41 deletions(-) diff --git a/test-docs/test-backlog.md b/test-docs/test-backlog.md index caeada4..9530509 100644 --- a/test-docs/test-backlog.md +++ b/test-docs/test-backlog.md @@ -1,33 +1,45 @@ -## 남은 작업 +# 부하 테스트 백로그 -우선순위 순입니다. +## 현재 상태 -1. 실제 영상 부하용 Go/Pion 클라이언트 +- [x] Go/Pion 실제 영상 부하 클라이언트 구현 +- [x] k6 REST 세션 부하 스크립트 구현 +- [x] Prometheus 애플리케이션 메트릭 추가 +- [x] Prometheus/Grafana Compose 및 대시보드 파일 추가 +- [x] 720p 30FPS VP8/IVF 기준 영상 1종 준비 +- [x] Windows/WSL2 Ubuntu 24.04 서버 환경 구성 +- [x] RTX 3060 Ti CUDA PyTorch 실제 연산 확인 +- [x] Python 전체 테스트 통과: 278개 +- [x] WSL mirrored networking 적용 +- [x] 맥북과 Linux 서버 간 최초 WebRTC 미디어 연결 -현재 k6는 REST 세션 부하만 만듭니다. 최대 동시 방송 수를 측정하려면 다음 기능을 가진 클라이언트가 필요합니다. +맥북 공인 IP로 범위를 제한한 Windows 방화벽 규칙을 적용했고, `bypass` 모드에서 +30초 원격 미디어 smoke test에 성공했습니다. ICE는 `completed`, PeerConnection은 +`connected`에 도달했으며 송신 약 30FPS와 수신 약 29.5FPS를 확인했습니다. -- 영상 파일 반복 송신 -- 처리 영상 수신 -- 다중 PeerConnection -- Offer/Answer와 ICE 교환 -- 수신 FPS, RTT, jitter, packet loss 기록 -- 계단식·스파이크·지속 부하 -- CSV/JSON 결과 저장 +## 우선순위 1: 최초 원격 WebRTC smoke test -이것이 본 성능 시험을 시작하기 위한 가장 큰 잔여 작업입니다. +1. [x] 맥북의 접속 IP 확인 +2. [x] 해당 IP로 제한한 Windows 방화벽 규칙 추가 +3. [x] 서버를 `AI_PRIVACY_MODE=bypass`로 실행 +4. [x] 맥북에서 `/health`, `/webrtc/config` 확인 +5. [x] Pion 클라이언트 1개를 30초간 실행 +6. [x] 송신·수신 FPS, ICE 상태, 오류 및 결과 CSV/JSON 확인 +7. [ ] `fixed_delay`, `real` 모드에서 각각 1개 연결 확인 -2. End-to-End 지연 측정 +## 우선순위 2: End-to-End 지연 측정 -아직 서버 내부 프레임 처리 시간만 측정합니다. 송신부터 반환 영상 수신까지 측정하려면 다음 중 하나가 필요합니다. +현재 서버 내부 프레임 처리 시간만 측정합니다. 송신부터 처리 영상 수신까지 +측정하려면 다음 중 하나가 필요합니다. -- 영상에 프레임 번호·타임코드 삽입 +- 영상에 프레임 번호와 타임코드 삽입 - RTP header extension으로 `frame_id`와 timestamp 전달 -첫 구현은 타임코드 영상 방식이 현실적입니다. +첫 구현은 대표 클라이언트 1~3개가 타임코드 영상을 판독하는 방식으로 진행합니다. -3. 단계별 처리 지연 +## 우선순위 3: 단계별 처리 지연 -현재는 프레임 전체 처리 시간을 측정합니다. 플랜의 병목 분석을 위해 다음을 분리해야 합니다. +병목 분석을 위해 전체 프레임 처리 시간을 다음 단계로 분리해야 합니다. - decode - AI detection @@ -35,43 +47,34 @@ - blur - encode -4. Docker 설치 및 모니터링 스택 검증 +## 우선순위 4: 모니터링 스택 검증 -Compose 파일은 추가했지만 현재 PC에는 Docker가 설치되어 있지 않아 실제 컨테이너 기동 검증은 아직 못 했습니다. Docker Desktop 설치 후: +Docker Desktop 또는 실제 Linux Docker 환경에서 다음 스택을 기동하고 검증합니다. ```bash docker compose -f docker-compose.test.yml up -d ``` -접속 주소: +- Prometheus: +- Grafana: (`admin` / `admin`) -```text -Prometheus: http://127.0.0.1:9090 -Grafana: http://127.0.0.1:3000 -계정: admin / admin -``` - -5. 시스템·GPU 자원 메트릭 - -애플리케이션 메트릭 외에 다음 수집기가 필요합니다. +## 우선순위 5: 시스템·GPU 자원 메트릭 - CPU/RAM: Node Exporter - 컨테이너: cAdvisor -- GPU/VRAM: DCGM Exporter +- GPU/VRAM: DCGM Exporter 또는 `nvidia-smi` 주기 수집기 -WSL2에서 DCGM Exporter가 제한되면 `nvidia-smi` 주기 수집기를 대신 사용할 수 있습니다. +## 우선순위 6: 고정 테스트 영상 확대 -6. 고정 테스트 영상 - -720p 30FPS 기준으로 적어도 다음 세 종류가 필요합니다. +현재 기준 영상 1종은 준비됐습니다. 본 시험에는 다음 3종을 고정해야 합니다. - 얼굴 1명, 움직임 적음 - 얼굴 4명, 움직임 보통 - 얼굴 다수, 움직임 많음 -7. 네트워크 장애 자동화 +## 우선순위 7: 네트워크 장애 자동화 -`tc netem`은 설치됐지만 시나리오 자동화 스크립트는 아직 없습니다. +`tc netem` 시나리오와 자동 복구 스크립트가 필요합니다. - 지연 50/100/150ms - jitter @@ -79,8 +82,7 @@ WSL2에서 DCGM Exporter가 제한되면 `nvidia-smi` 주기 수집기를 대신 - 3초 단절 - 테스트 종료 시 자동 복구 -8. Go 서버 - -현재 저장소는 Python 서버뿐입니다. Python 기준선 시험은 가능하지만 최종 언어 비교는 동일 API와 AI 의미를 갖는 Go 서버 구현 후 가능합니다. +## 우선순위 8: Go 서버 비교 구현 -현재 상태에서는 단일 브라우저 GPU 기능 시험과 k6 제어부하 시험까지 가능하며, 다음 구현 목표는 Go/Pion 실제 영상 부하 클라이언트입니다. \ No newline at end of file +현재 저장소는 Python 서버입니다. Python 기준선 시험을 완료한 뒤 동일 API와 AI 의미를 +갖는 Go 서버를 구현해야 최종 언어 비교가 가능합니다. From 7697cdd5beb9d329e1fd4f8836e0d8b3522b91da Mon Sep 17 00:00:00 2001 From: Finefinee Date: Sun, 12 Jul 2026 16:16:17 +0900 Subject: [PATCH 03/11] docs: update test backlog --- loadtest/windows/configure-firewall.ps1 | 42 +++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 loadtest/windows/configure-firewall.ps1 diff --git a/loadtest/windows/configure-firewall.ps1 b/loadtest/windows/configure-firewall.ps1 new file mode 100644 index 0000000..7925bb4 --- /dev/null +++ b/loadtest/windows/configure-firewall.ps1 @@ -0,0 +1,42 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidatePattern('^(?:\d{1,3}\.){3}\d{1,3}$')] + [string]$RemoteAddress +) + +$ErrorActionPreference = 'Stop' + +$rules = @( + @{ + DisplayName = 'InnoLive Mac TCP 8000' + Protocol = 'TCP' + LocalPort = '8000' + }, + @{ + DisplayName = 'InnoLive Mac WebRTC UDP' + Protocol = 'UDP' + LocalPort = '50000-60000' + } +) + +foreach ($definition in $rules) { + $existing = Get-NetFirewallRule ` + -DisplayName $definition.DisplayName ` + -ErrorAction SilentlyContinue + + if ($null -ne $existing) { + Remove-NetFirewallRule -DisplayName $definition.DisplayName + } + + New-NetFirewallRule ` + -DisplayName $definition.DisplayName ` + -Direction Inbound ` + -Action Allow ` + -Protocol $definition.Protocol ` + -LocalPort $definition.LocalPort ` + -RemoteAddress $RemoteAddress ` + -Profile Any | Out-Null +} + +Write-Host "Configured InnoLive firewall access for $RemoteAddress" From 8a78e36a9a9638ceef5334e1e14de6b8c4ccf659 Mon Sep 17 00:00:00 2001 From: Finefinee Date: Mon, 13 Jul 2026 01:15:41 +0900 Subject: [PATCH 04/11] Establish Python load-test baseline --- .gitignore | 1 + app/services/ai/filter.py | 4 + app/services/metrics.py | 9 ++ loadtest/scripts/collect-server-baseline.sh | 71 ++++++++++ loadtest/windows/configure-firewall.ps1 | 26 +++- privacy_blur/core.py | 5 + privacy_blur/runtime.py | 27 +++- requirements.txt | 2 +- test-docs/test-backlog.md | 61 +++++++-- test-docs/test-result.md | 137 +++++++++++++++++--- tests/test_identity_performance.py | 13 +- 11 files changed, 320 insertions(+), 36 deletions(-) create mode 100644 loadtest/scripts/collect-server-baseline.sh diff --git a/.gitignore b/.gitignore index a7a2b62..47aa6c0 100644 --- a/.gitignore +++ b/.gitignore @@ -13,5 +13,6 @@ data/ loadtest/pion-client/results/ loadtest/pion-client/.gocache/ loadtest/pion-client/*.exe +loadtest/server-results/ loadtest/videos/* !loadtest/videos/README.md diff --git a/app/services/ai/filter.py b/app/services/ai/filter.py index 0ecbd5c..5bf2da2 100644 --- a/app/services/ai/filter.py +++ b/app/services/ai/filter.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any from app.core.config import Settings, get_settings +from app.services.metrics import AI_STAGE_DURATION from privacy_blur import ( FacePrivacyFilter, PrivacyBlurConfig, @@ -131,6 +132,9 @@ def initialize_privacy_runtime() -> PrivacyBlurConfig | None: return None if _runtime is None: _runtime = initialize_runtime(config) + _runtime.set_stage_observer( + lambda stage, seconds: AI_STAGE_DURATION.labels(stage).observe(seconds) + ) _store_identity_features( getattr(config, "identity_client_id", None), getattr(_runtime, "reference_features", None), diff --git a/app/services/metrics.py b/app/services/metrics.py index 1b49d72..3be84ee 100644 --- a/app/services/metrics.py +++ b/app/services/metrics.py @@ -39,6 +39,15 @@ ("mode",), buckets=(0.005, 0.01, 0.02, 0.033, 0.05, 0.075, 0.1, 0.2, 0.5, 1.0), ) +AI_STAGE_DURATION = Histogram( + "innolive_ai_stage_duration_seconds", + ( + "Time spent in an AI video pipeline stage. The encode stage measures " + "BGR-to-AV-frame construction, not aiortc codec compression." + ), + ("stage",), + buckets=(0.001, 0.0025, 0.005, 0.01, 0.02, 0.033, 0.05, 0.075, 0.1, 0.2, 0.5, 1.0), +) PROCESSING_QUEUE_SIZE = Gauge( "innolive_processing_queue_size", "Current shared AI processing queue depth.", diff --git a/loadtest/scripts/collect-server-baseline.sh b/loadtest/scripts/collect-server-baseline.sh new file mode 100644 index 0000000..25ff404 --- /dev/null +++ b/loadtest/scripts/collect-server-baseline.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 2 || $# -gt 3 ]]; then + echo "usage: $0 OUTPUT_DIR DURATION_SECONDS [INTERVAL_SECONDS]" >&2 + exit 2 +fi + +output_dir=$1 +duration_seconds=$2 +interval_seconds=${3:-1} + +mkdir -p "$output_dir" + +server_pid=$(pgrep -f '/opt/inno-live-venv/bin/python -m uvicorn app.main:app' | head -n 1 || true) +if [[ -z "$server_pid" ]]; then + echo "uvicorn server process not found" >&2 + exit 1 +fi + +echo "waiting for an active WebRTC session (timeout: 600s)" +wait_deadline=$((SECONDS + 600)) +while ((SECONDS < wait_deadline)); do + active_sessions=$(curl --fail --silent http://127.0.0.1:8000/metrics | + awk '$1 == "innolive_active_sessions" { print $2; exit }') + if awk -v value="${active_sessions:-0}" 'BEGIN { exit !(value > 0) }'; then + echo "active session detected; starting ${duration_seconds}s collection" + break + fi + sleep 1 +done + +if ((SECONDS >= wait_deadline)); then + echo "timed out waiting for an active WebRTC session" >&2 + exit 1 +fi + +curl --fail --silent http://127.0.0.1:8000/metrics > "$output_dir/metrics-initial.prom" +curl --fail --silent http://127.0.0.1:8000/health > "$output_dir/health-initial.json" + +echo "timestamp,gpu_util_pct,gpu_memory_mib" > "$output_dir/gpu.csv" +echo "timestamp,active_sessions,queue_size,received_real,processed_real,dropped_real,failures_real" > "$output_dir/app.csv" + +pidstat -h -r -u -p "$server_pid" "$interval_seconds" "$duration_seconds" > "$output_dir/pidstat.txt" & +pidstat_pid=$! +/opt/inno-live-venv/bin/py-spy record --pid "$server_pid" --duration "$duration_seconds" --rate 50 --format speedscope --output "$output_dir/py-spy-speedscope.json" & +py_spy_pid=$! + +samples=$((duration_seconds / interval_seconds)) +for ((sample = 0; sample < samples; sample++)); do + timestamp=$(date --iso-8601=seconds) + gpu_values=$(nvidia-smi --query-gpu=utilization.gpu,memory.used --format=csv,noheader,nounits | head -n 1 | tr -d ' ') + echo "$timestamp,$gpu_values" >> "$output_dir/gpu.csv" + + metrics=$(curl --fail --silent http://127.0.0.1:8000/metrics) + metric_value() { + local pattern=$1 + local value + value=$(awk -v pattern="$pattern" '$0 ~ pattern { print $NF; exit }' <<< "$metrics") + echo "${value:-0}" + } + echo "$timestamp,$(metric_value '^innolive_active_sessions '),$(metric_value '^innolive_processing_queue_size '),$(metric_value '^innolive_frame_received_total\\{mode="real"\\} '),$(metric_value '^innolive_frame_processed_total\\{mode="real"\\} '),$(metric_value '^innolive_frame_dropped_total\\{mode="real"\\} '),$(metric_value '^innolive_frame_failures_total\\{mode="real"\\} ')" >> "$output_dir/app.csv" + + sleep "$interval_seconds" +done + +wait "$pidstat_pid" +wait "$py_spy_pid" +curl --fail --silent http://127.0.0.1:8000/health > "$output_dir/health-final.json" +curl --fail --silent http://127.0.0.1:8000/sessions > "$output_dir/sessions-final.json" +curl --fail --silent http://127.0.0.1:8000/metrics > "$output_dir/metrics-final.prom" diff --git a/loadtest/windows/configure-firewall.ps1 b/loadtest/windows/configure-firewall.ps1 index 7925bb4..79d2ba1 100644 --- a/loadtest/windows/configure-firewall.ps1 +++ b/loadtest/windows/configure-firewall.ps1 @@ -6,6 +6,7 @@ param( ) $ErrorActionPreference = 'Stop' +$wslCreatorId = '{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}' $rules = @( @{ @@ -37,6 +38,29 @@ foreach ($definition in $rules) { -LocalPort $definition.LocalPort ` -RemoteAddress $RemoteAddress ` -Profile Any | Out-Null + + # WSL2 mirrored networking has a separate Hyper-V firewall layer. Windows + # Defender rules alone do not allow inbound traffic through that layer. + $hyperVRuleName = ($definition.DisplayName -replace '[^A-Za-z0-9]', '-') + $existingHyperVRule = Get-NetFirewallHyperVRule ` + -Name $hyperVRuleName ` + -ErrorAction SilentlyContinue + + if ($null -ne $existingHyperVRule) { + Remove-NetFirewallHyperVRule -Name $hyperVRuleName + } + + New-NetFirewallHyperVRule ` + -Name $hyperVRuleName ` + -DisplayName $definition.DisplayName ` + -Direction Inbound ` + -VMCreatorId $wslCreatorId ` + -Action Allow ` + -Enabled True ` + -Profiles Any ` + -Protocol $definition.Protocol ` + -LocalPorts $definition.LocalPort ` + -RemoteAddresses $RemoteAddress | Out-Null } -Write-Host "Configured InnoLive firewall access for $RemoteAddress" +Write-Host "Configured Windows and WSL Hyper-V firewall access for $RemoteAddress" diff --git a/privacy_blur/core.py b/privacy_blur/core.py index c76e209..6b39b07 100644 --- a/privacy_blur/core.py +++ b/privacy_blur/core.py @@ -2,6 +2,7 @@ from dataclasses import dataclass import threading +from time import perf_counter from typing import Any import numpy as np @@ -393,9 +394,13 @@ async def apply_async(self, frame: Any) -> Any: return await self.engine.process_async(frame) video_frame_type = _video_frame_type() if video_frame_type is not None and isinstance(frame, video_frame_type): + decode_started = perf_counter() image = frame.to_ndarray(format="bgr24") + self.engine.runtime.observe_stage("decode", perf_counter() - decode_started) processed = await self.engine.process_async(image) + encode_started = perf_counter() protected = video_frame_type.from_ndarray(processed, format="bgr24") + self.engine.runtime.observe_stage("encode", perf_counter() - encode_started) protected.pts = frame.pts protected.time_base = frame.time_base return protected diff --git a/privacy_blur/runtime.py b/privacy_blur/runtime.py index 2502393..7c66e17 100644 --- a/privacy_blur/runtime.py +++ b/privacy_blur/runtime.py @@ -11,6 +11,7 @@ from queue import Empty, Full, Queue import threading import time +from collections.abc import Callable from typing import TYPE_CHECKING, Any import cv2 @@ -181,6 +182,7 @@ def __init__(self, config: PrivacyBlurConfig): maxsize=config.max_pending_frames * config.max_batch_size ) self._closed = False + self._stage_observer: Callable[[str, float], None] | None = None self._stats_lock = threading.Lock() self._batches = 0 self._frames = 0 @@ -215,6 +217,20 @@ def __init__(self, config: PrivacyBlurConfig): self._worker.start() self._identity_worker.start() + def set_stage_observer( + self, observer: Callable[[str, float], None] | None + ) -> None: + self._stage_observer = observer + + def observe_stage(self, stage: str, seconds: float) -> None: + observer = self._stage_observer + if observer is None: + return + try: + observer(stage, seconds) + except Exception: + LOGGER.exception("AI stage observer failed: stage=%s", stage) + def _create_ort_session(self, path: Path) -> ort.InferenceSession: available = ort.get_available_providers() if self.cuda_enabled and "CUDAExecutionProvider" not in available: @@ -547,10 +563,12 @@ def _run_batch(self, requests: list[_FrameRequest]) -> list[np.ndarray]: ] detections: dict[int, list[FaceDetection]] = {} if detect_indices: + detection_started = time.perf_counter() detected = self._predict_yolo( [requests[index].frame for index in detect_indices], config=runtime_config, ) + self.observe_stage("detection", time.perf_counter() - detection_started) detections = dict(zip(detect_indices, detected)) identity_work: list[_IdentityRequest] = [] @@ -576,11 +594,14 @@ def _run_batch(self, requests: list[_FrameRequest]) -> list[np.ndarray]: if identity_work: self._submit_identity_work(identity_work) privacy_boxes = [request.engine.finish_frame() for request in requests] - return self._blur_frames( + blur_started = time.perf_counter() + blurred = self._blur_frames( [request.frame for request in requests], privacy_boxes, config=runtime_config, ) + self.observe_stage("blur", time.perf_counter() - blur_started) + return blurred def _submit_identity_work(self, requests: list[_IdentityRequest]) -> None: dropped: list[_IdentityRequest] = [] @@ -599,6 +620,7 @@ def _submit_identity_work(self, requests: list[_IdentityRequest]) -> None: request.engine.apply_identity_matches([(request.track, -1.0)]) def _run_identity_batch(self, requests: list[_IdentityRequest]) -> None: + identity_started = time.perf_counter() active_requests = [ request for request in requests @@ -625,6 +647,7 @@ def _run_identity_batch(self, requests: list[_IdentityRequest]) -> None: for request in active_requests ] ) + self.observe_stage("identity", time.perf_counter() - identity_started) grouped: dict[FacePrivacyEngine, list[tuple[FaceTrack, float]]] = defaultdict( list ) @@ -662,7 +685,7 @@ def _predict_yolo( max_det=config.face_max_detections, verbose=False, device=predict_device, - half=predict_half, + quantize=16 if predict_half else None, batch=len(frames), ) diff --git a/requirements.txt b/requirements.txt index 67f0a7f..85a37d9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,7 @@ python-dotenv==1.2.2 prometheus-client==0.23.1 numpy>=1.24 opencv-python-headless>=4.8 -ultralytics>=8.0 +ultralytics>=8.4.92 torch>=2.0 torchvision>=0.15 onnx>=1.17 diff --git a/test-docs/test-backlog.md b/test-docs/test-backlog.md index 9530509..4e6507a 100644 --- a/test-docs/test-backlog.md +++ b/test-docs/test-backlog.md @@ -25,9 +25,35 @@ 4. [x] 맥북에서 `/health`, `/webrtc/config` 확인 5. [x] Pion 클라이언트 1개를 30초간 실행 6. [x] 송신·수신 FPS, ICE 상태, 오류 및 결과 CSV/JSON 확인 -7. [ ] `fixed_delay`, `real` 모드에서 각각 1개 연결 확인 +7. [x] `fixed_delay`, `real` 모드에서 각각 1개 연결 확인 -## 우선순위 2: End-to-End 지연 측정 +## 우선순위 2: Python 단일 세션 기준선 + +합격 기준은 720p 30FPS 입력의 90%인 27FPS입니다. + +- [x] `quantize=16` 적용 후 실제 GPU 예열 시험 +- [x] 예열 후 단일 세션 5분 기준선 시험 +- [x] 첫 30초를 제외한 수신·서버 처리 FPS 평균 및 p95 계산 +- [x] 클라이언트 프레임 손실과 서버 stale drop 기록 +- [x] Prometheus, `nvidia-smi`, `pidstat`, `py-spy` 결과 저장 +- [x] 종료 후 활성 세션과 처리 큐가 0인지 확인 + +2026-07-13 기준선은 수신 25.51FPS, 서버 처리 25.52FPS, 송수신 프레임 차이 +14.98%, stale drop 17.47%로 실패했습니다. 따라서 다중 세션 시험은 보류합니다. + +단일 세션에서 평균 27FPS 또는 드롭률 10% 기준을 만족하지 못하면 다중 세션 +시험을 중단하고 decode·AI·blur·encode 단계별 병목 계측을 먼저 구현합니다. + +## 우선순위 3: 계단식·지속·스파이크 부하 + +단일 세션 기준선을 통과한 뒤 진행합니다. + +- [ ] `1 → 2 → 4 → 8` 세션을 각 5분 유지 +- [ ] 최초 기준 위반 단계 주변을 1세션 단위로 재시험 +- [ ] 최대 안정 세션의 70~80%로 1~2시간 지속 시험 +- [ ] `0 → 최대 후보 → 0 → 최대 후보` 스파이크·재연결 시험 + +## 우선순위 4: End-to-End 지연 측정 현재 서버 내부 프레임 처리 시간만 측정합니다. 송신부터 처리 영상 수신까지 측정하려면 다음 중 하나가 필요합니다. @@ -37,17 +63,26 @@ 첫 구현은 대표 클라이언트 1~3개가 타임코드 영상을 판독하는 방식으로 진행합니다. -## 우선순위 3: 단계별 처리 지연 +## 우선순위 5: 단계별 처리 지연 병목 분석을 위해 전체 프레임 처리 시간을 다음 단계로 분리해야 합니다. -- decode -- AI detection -- identity comparison -- blur -- encode +- [x] Prometheus 단계별 histogram 추가 +- [x] decode 계측 +- [x] AI detection 계측 +- [x] identity comparison 계측 +- [x] blur 계측 +- [x] encode 입력 프레임 생성 계측 +- [x] 계측 적용 서버를 재시작하고 예열 후 단일 세션 5분 재프로파일 +- [x] 단계별 평균과 p95를 비교해 최우선 최적화 대상 선정 +- [ ] detection 호출·전처리·추론·후처리를 세분화하고 최적화 +- [ ] blur 마스크 생성·Gaussian 연산·CPU 반환을 세분화하고 최적화 +- [ ] 최적화 후 단일 세션 5분 기준선 재시험 + +현재 `encode` 단계는 BGR 배열을 AV 프레임으로 만드는 시간입니다. aiortc 내부 VP8 +코덱 압축 시간은 포함하지 않으며, 필요하면 aiortc encoder 계층에 별도 훅을 추가합니다. -## 우선순위 4: 모니터링 스택 검증 +## 우선순위 6: 모니터링 스택 검증 Docker Desktop 또는 실제 Linux Docker 환경에서 다음 스택을 기동하고 검증합니다. @@ -58,13 +93,13 @@ docker compose -f docker-compose.test.yml up -d - Prometheus: - Grafana: (`admin` / `admin`) -## 우선순위 5: 시스템·GPU 자원 메트릭 +## 우선순위 7: 시스템·GPU 자원 메트릭 - CPU/RAM: Node Exporter - 컨테이너: cAdvisor - GPU/VRAM: DCGM Exporter 또는 `nvidia-smi` 주기 수집기 -## 우선순위 6: 고정 테스트 영상 확대 +## 우선순위 8: 고정 테스트 영상 확대 현재 기준 영상 1종은 준비됐습니다. 본 시험에는 다음 3종을 고정해야 합니다. @@ -72,7 +107,7 @@ docker compose -f docker-compose.test.yml up -d - 얼굴 4명, 움직임 보통 - 얼굴 다수, 움직임 많음 -## 우선순위 7: 네트워크 장애 자동화 +## 우선순위 9: 네트워크 장애 자동화 `tc netem` 시나리오와 자동 복구 스크립트가 필요합니다. @@ -82,7 +117,7 @@ docker compose -f docker-compose.test.yml up -d - 3초 단절 - 테스트 종료 시 자동 복구 -## 우선순위 8: Go 서버 비교 구현 +## 우선순위 10: Go 서버 비교 구현 현재 저장소는 Python 서버입니다. Python 기준선 시험을 완료한 뒤 동일 API와 AI 의미를 갖는 Go 서버를 구현해야 최종 언어 비교가 가능합니다. diff --git a/test-docs/test-result.md b/test-docs/test-result.md index 18dd53c..09a8921 100644 --- a/test-docs/test-result.md +++ b/test-docs/test-result.md @@ -1,27 +1,128 @@ -# 9. 최종 결과표 형식 +# 부하 테스트 결과 -## 고정 사용자 수 성능 +## 합격 기준 + +| 항목 | 기준 | +| --- | --- | +| 입력 프로필 | 1280×720, 30FPS, VP8/IVF | +| 평균 클라이언트 수신 FPS | 27FPS 이상 | +| 평균 서버 처리 FPS | 27FPS 이상 | +| 프레임 손실·드롭 | 10% 이하 | +| 연결 성공률 | 99% 이상 | +| 서버 처리 오류율 | 0.1% 이하 | +| 처리 큐 | 시험 종료까지 지속 증가하지 않음 | +| E2E p50/p95/p99 | 타임코드 측정 구현 전까지 측정 보류 | + +최종 최대 안정 세션은 위 기준을 모두 만족하며 30분 이상 유지 가능한 동시 방송 +세션 수로 정의합니다. 5분 시험은 후보 구간을 찾는 예비 용량 시험입니다. + +## 2026-07-12 원격 smoke test + +서버는 Windows/WSL2 Ubuntu 24.04, RTX 3060 Ti 8GB이고 부하 클라이언트는 +맥북에서 실행했습니다. 서버 방화벽은 맥북 공인 IP만 허용했습니다. + +| 모드 | 시간 | 송신 프레임 | 수신 프레임 | 수신 FPS | 결과 | +| --- | ---: | ---: | ---: | ---: | --- | +| bypass | 30초 | 899 | 884 | 약 29.5 | 통과 | +| fixed_delay 20ms | 30초 | 898 | 859 | 약 28.6 | 통과 | +| real cold | 30초 | 899 | 0 | 0 | 예열 결과, 판정 제외 | +| real warm | 30초 | 899 | 782 | 약 26.1 | 기준 미달 | + +### 서버 측 관찰 + +- `bypass`: 수신·처리 각각 898프레임, 처리 실패 0 +- `fixed_delay`: 서버 수신 876, 처리 875, stale drop 9, 평균 인위 지연 20.32ms +- `real cold`: 첫 AI 배치 약 46.4초로 30초 시험 종료 전 반환 프레임 없음 +- `real warm`: 서버 약 800프레임 처리, stale drop 98, 처리 실패 0 +- `real warm` 추론 약 24~30ms/프레임, GPU 메모리 약 1GB +- 모든 시험에서 ICE `completed`, PeerConnection `connected`, 종료 후 활성 세션 0 + +`real warm`은 현재 27FPS와 10% 드롭 기준을 만족하지 못합니다. 당시 Ultralytics가 +매 프레임 `half` 폐기 경고를 출력했으므로 `quantize=16` 적용 후 5분 기준선을 다시 +측정합니다. + +## Python 단일 세션 5분 기준선 + +첫 30초는 안정화 구간으로 제외하고 이후 4분 30초를 집계합니다. + +2026-07-13에 `quantize=16`과 별도 예열 실행을 적용해 측정했습니다. Pion 시험은 +1개 연결에 성공했고 8,999프레임을 송신해 7,595프레임을 수신했습니다. 아래 FPS와 +드롭 수치는 안정화 구간을 제외한 270초 창의 카운터 차이로 계산했습니다. + +| 항목 | 결과 | 합격 여부 | +| --- | ---: | --- | +| 평균 수신 FPS | 25.51 | 실패 (< 27) | +| 수신 FPS p95 | 27.60 | 참고값 | +| 평균 서버 처리 FPS | 25.52 | 실패 (< 27) | +| 서버 처리 지연 평균 / p95 버킷 | 31.76ms / 50ms 이하 | 참고값 | +| 클라이언트 송수신 프레임 차이 | 14.98% | 실패 (> 10%) | +| RTP 패킷 손실률 | 최종 0.177%, 최대 0.288% | 통과 | +| 서버 stale drop | 1,221 / 수신 6,991 (17.47%) | 실패 (> 10%) | +| 연결 성공률 | 100% (1/1) | 통과 | +| 처리 오류율 | 0% (0건) | 통과 | +| 평균/최대 CPU | 145.02% / 161% | 참고값 | +| 평균/최대 RSS | 3,479.57 / 3,479.59MiB | 참고값 | +| 평균/최대 GPU 사용률 | 33.57% / 58% | 참고값 | +| 평균/최대 VRAM | 1,043.53 / 1,082MiB | 참고값 | +| 종료 후 활성 세션/큐 | 0 / 0 | 통과 | +| E2E p50/p95/p99 | 측정 보류 | - | + +판정은 **단일 세션 기준선 실패**입니다. 네트워크 RTP 손실은 낮지만 서버 처리량이 +입력 30FPS보다 낮아 stale frame이 누적 폐기됐습니다. 계획에 따라 2·4·8 세션 확대는 +중단하고 단계별 병목 계측 후 단일 세션을 다시 측정합니다. + +수집 원본은 Git에서 제외된 `loadtest/server-results/real-single-5m/`에 저장했습니다. +`py-spy`는 12,783개 샘플을 기록했으며, CPU 프로파일은 +`py-spy-speedscope.json`에서 확인할 수 있습니다. + +## Python 단일 세션 단계별 재프로파일 + +2026-07-13에 단계별 Prometheus histogram을 적용하고 별도 예열 후 다시 5분간 +측정했습니다. 첫 30초를 제외한 270초 창의 결과입니다. + +| 항목 | 결과 | 판정 | +| --- | ---: | --- | +| 클라이언트 수신 FPS | 25.66 | 실패 (< 27) | +| 서버 처리 FPS | 25.67 | 실패 (< 27) | +| 송수신 프레임 차이 | 14.48% | 실패 (> 10%) | +| 서버 stale drop | 1,193 / 7,033 (16.96%) | 실패 (> 10%) | +| RTP 패킷 손실 | 0% | 통과 | +| 연결 성공 / 처리 오류 | 100% / 0% | 통과 | +| 평균/최대 CPU | 146.57% / 163% | 참고값 | +| 평균/최대 GPU | 33.32% / 57% | 참고값 | +| 평균/최대 RSS | 3,972.92 / 3,979.37MiB | 참고값 | +| 평균/최대 VRAM | 1,083.98 / 1,142MiB | 참고값 | +| 종료 후 활성 세션/큐 | 0 / 0 | 통과 | + +예열 누적 카운터를 차감한 단계별 평균은 다음과 같습니다. + +| 단계 | 평균 | 누적 histogram 기준 p95 버킷 | +| --- | ---: | ---: | +| decode | 0.64ms | 1ms 이하 | +| detection | 16.42ms | 33ms 이하 | +| identity | 미실행 | - | +| blur | 8.45ms | 20ms 이하 | +| encode 입력 프레임 생성 | 1.13ms | 5ms 이하 | + +이번 수집기는 종료 histogram만 저장했으므로 평균은 예열 직후 카운터를 차감해 +계산했지만 p95는 예열 샘플이 약 5.6% 포함된 누적 버킷입니다. 이후 수집부터 시작·종료 +Prometheus 스냅샷을 모두 저장하도록 수정했습니다. + +`py-spy` GPU worker 표본에서는 detection 경로가 포괄 시간의 62.8%, blur 경로가 +36.0%였습니다. 단일 세션 처리량 개선의 우선순위는 **detection 최적화 후 blur +최적화**입니다. GPU 사용률이 평균 33.32%라 GPU 연산 포화보다는 단일 프레임 호출과 +CPU/GPU 동기화 비용의 영향이 큰 상태입니다. + +## 향후 Python/Go 비교표 | 항목 | Python | Go | 차이 | | --- | --- | --- | --- | -| 동시 스트림 | 10 | 10 | 동일 | -| p50 지연 | | | | -| p95 지연 | | | | -| p99 지연 | | | | +| 최대 연결 가능 세션 | | | | +| 최대 안정 세션 | | | | +| 운영 권장 세션 | | | | | 평균 처리 FPS | | | | | 프레임 드롭률 | | | | | CPU 사용률 | | | | | 메모리 사용량 | | | | -| 세션당 메모리 | | | | | GPU 사용률 | | | | - -## 최대 수용량 비교 - -| 항목 | Python | Go | -| --- | --- | --- | -| 최대 연결 가능 세션 | | | -| 최대 안정 세션 | | | -| 운영 권장 세션 | | | -| 세션당 CPU | | | -| 세션당 메모리 | | | -| 한계 도달 원인 | | | \ No newline at end of file +| 한계 도달 원인 | | | | diff --git a/tests/test_identity_performance.py b/tests/test_identity_performance.py index beccc21..c5ac646 100644 --- a/tests/test_identity_performance.py +++ b/tests/test_identity_performance.py @@ -26,6 +26,17 @@ def _detection(confidence: float = 0.9) -> FaceDetection: ) +def test_runtime_stage_observer_receives_measurements() -> None: + runtime = SharedGpuRuntime.__new__(SharedGpuRuntime) + measurements: list[tuple[str, float]] = [] + runtime._stage_observer = None + + runtime.set_stage_observer(lambda stage, seconds: measurements.append((stage, seconds))) + runtime.observe_stage("detection", 0.012) + + assert measurements == [("detection", 0.012)] + + def _engine(**overrides) -> FacePrivacyEngine: config = PrivacyBlurConfig( enable_identity_exclusion=True, @@ -224,7 +235,7 @@ def test_external_reference_detector_does_not_take_live_detector_lock() -> None: runtime.detector.predict.assert_not_called() reference_detector.predict.assert_called_once() assert reference_detector.predict.call_args.kwargs["device"] == "cpu" - assert reference_detector.predict.call_args.kwargs["half"] is False + assert reference_detector.predict.call_args.kwargs["quantize"] is None def test_prepare_identity_uses_isolated_cpu_detector(tmp_path) -> None: From 920bcd4af8ca8ef34e0a80f5d5de521bff886866 Mon Sep 17 00:00:00 2001 From: Finefinee Date: Thu, 16 Jul 2026 21:45:48 +0900 Subject: [PATCH 05/11] Record Python bypass baseline --- loadtest/scripts/collect-server-baseline.sh | 19 ++++++++++----- test-docs/test-backlog.md | 2 ++ test-docs/test-result.md | 27 +++++++++++++++++++++ 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/loadtest/scripts/collect-server-baseline.sh b/loadtest/scripts/collect-server-baseline.sh index 25ff404..757ed9d 100644 --- a/loadtest/scripts/collect-server-baseline.sh +++ b/loadtest/scripts/collect-server-baseline.sh @@ -1,14 +1,21 @@ #!/usr/bin/env bash set -euo pipefail -if [[ $# -lt 2 || $# -gt 3 ]]; then - echo "usage: $0 OUTPUT_DIR DURATION_SECONDS [INTERVAL_SECONDS]" >&2 +if [[ $# -lt 2 || $# -gt 4 ]]; then + echo "usage: $0 OUTPUT_DIR DURATION_SECONDS [INTERVAL_SECONDS] [MODE]" >&2 exit 2 fi output_dir=$1 duration_seconds=$2 interval_seconds=${3:-1} +mode=${4:-real} +wait_timeout_seconds=${WAIT_TIMEOUT_SECONDS:-3600} + +if [[ "$mode" != "bypass" && "$mode" != "fixed_delay" && "$mode" != "real" ]]; then + echo "mode must be bypass, fixed_delay, or real" >&2 + exit 2 +fi mkdir -p "$output_dir" @@ -18,8 +25,8 @@ if [[ -z "$server_pid" ]]; then exit 1 fi -echo "waiting for an active WebRTC session (timeout: 600s)" -wait_deadline=$((SECONDS + 600)) +echo "waiting for an active WebRTC session (timeout: ${wait_timeout_seconds}s)" +wait_deadline=$((SECONDS + wait_timeout_seconds)) while ((SECONDS < wait_deadline)); do active_sessions=$(curl --fail --silent http://127.0.0.1:8000/metrics | awk '$1 == "innolive_active_sessions" { print $2; exit }') @@ -39,7 +46,7 @@ curl --fail --silent http://127.0.0.1:8000/metrics > "$output_dir/metrics-initia curl --fail --silent http://127.0.0.1:8000/health > "$output_dir/health-initial.json" echo "timestamp,gpu_util_pct,gpu_memory_mib" > "$output_dir/gpu.csv" -echo "timestamp,active_sessions,queue_size,received_real,processed_real,dropped_real,failures_real" > "$output_dir/app.csv" +echo "timestamp,mode,active_sessions,queue_size,received,processed,dropped,failures" > "$output_dir/app.csv" pidstat -h -r -u -p "$server_pid" "$interval_seconds" "$duration_seconds" > "$output_dir/pidstat.txt" & pidstat_pid=$! @@ -59,7 +66,7 @@ for ((sample = 0; sample < samples; sample++)); do value=$(awk -v pattern="$pattern" '$0 ~ pattern { print $NF; exit }' <<< "$metrics") echo "${value:-0}" } - echo "$timestamp,$(metric_value '^innolive_active_sessions '),$(metric_value '^innolive_processing_queue_size '),$(metric_value '^innolive_frame_received_total\\{mode="real"\\} '),$(metric_value '^innolive_frame_processed_total\\{mode="real"\\} '),$(metric_value '^innolive_frame_dropped_total\\{mode="real"\\} '),$(metric_value '^innolive_frame_failures_total\\{mode="real"\\} ')" >> "$output_dir/app.csv" + echo "$timestamp,$mode,$(metric_value '^innolive_active_sessions '),$(metric_value '^innolive_processing_queue_size '),$(metric_value "^innolive_frame_received_total\\{mode=\"$mode\"\\} "),$(metric_value "^innolive_frame_processed_total\\{mode=\"$mode\"\\} "),$(metric_value "^innolive_frame_dropped_total\\{mode=\"$mode\"\\} "),$(metric_value "^innolive_frame_failures_total\\{mode=\"$mode\"\\} ")" >> "$output_dir/app.csv" sleep "$interval_seconds" done diff --git a/test-docs/test-backlog.md b/test-docs/test-backlog.md index 4e6507a..919a2d7 100644 --- a/test-docs/test-backlog.md +++ b/test-docs/test-backlog.md @@ -31,6 +31,8 @@ 합격 기준은 720p 30FPS 입력의 90%인 27FPS입니다. +- [x] bypass 단일 세션 5분 Python/aiortc 기준선 측정 +- [x] bypass CPU/RSS 및 `py-spy` 프로파일 수집 - [x] `quantize=16` 적용 후 실제 GPU 예열 시험 - [x] 예열 후 단일 세션 5분 기준선 시험 - [x] 첫 30초를 제외한 수신·서버 처리 FPS 평균 및 p95 계산 diff --git a/test-docs/test-result.md b/test-docs/test-result.md index 09a8921..b50c13e 100644 --- a/test-docs/test-result.md +++ b/test-docs/test-result.md @@ -41,6 +41,33 @@ 매 프레임 `half` 폐기 경고를 출력했으므로 `quantize=16` 적용 후 5분 기준선을 다시 측정합니다. +## Python bypass 단일 세션 5분 기준선 + +2026-07-16에 AI 처리를 제외한 Python/aiortc 미디어 경로를 측정했습니다. 첫 30초를 +제외한 270초 클라이언트 결과와 동일 조건 재실행의 서버 자원 결과를 결합했습니다. + +| 항목 | 결과 | 합격 여부 | +| --- | ---: | --- | +| 평균 송신 FPS | 30.00 | 참고값 | +| 평균 수신 FPS | 29.66 | 통과 | +| 수신 FPS p95 | 30.40 | 참고값 | +| 송수신 프레임 차이 | 1.15% | 통과 | +| RTP 패킷 손실 | 0% | 통과 | +| RTT 평균 / p95 | 18.39 / 111.45ms | 참고값 | +| jitter 평균 / p95 | 3.37 / 10.44ms | 참고값 | +| 서버 전체 실행 수신 / 처리 | 8,935 / 8,935 | 통과 | +| 서버 stale drop | 52 (약 0.58%) | 통과 | +| 서버 처리 오류 | 0 | 통과 | +| 평균/최대 CPU | 78.77% / 96% | 참고값 | +| 평균/최대 RSS | 852.60 / 852.98MiB | 참고값 | +| 종료 후 활성 세션/큐 | 0 / 0 | 통과 | + +판정은 **bypass 단일 세션 통과**입니다. 같은 입력에서 real은 약 25.66FPS와 +14.48% 프레임 차이를 보였으므로 네트워크나 기본 WebRTC 전달보다 AI detection·blur +경로가 단일 세션 목표 미달의 주원인입니다. bypass의 `py-spy` 인코딩 worker 표본에서는 +`encode`가 52.3%로 가장 큰 실행 지점이었으며, 이는 향후 Go/Pion bypass 비교에서 +CPU 차이를 설명하는 핵심 지표입니다. + ## Python 단일 세션 5분 기준선 첫 30초는 안정화 구간으로 제외하고 이후 4분 30초를 집계합니다. From 0d99999ed18a7bf218a566491d45861054bbc111 Mon Sep 17 00:00:00 2001 From: Finefinee Date: Thu, 16 Jul 2026 21:52:08 +0900 Subject: [PATCH 06/11] Revert "Record Python bypass baseline" This reverts commit 920bcd4af8ca8ef34e0a80f5d5de521bff886866. --- loadtest/scripts/collect-server-baseline.sh | 19 +++++---------- test-docs/test-backlog.md | 2 -- test-docs/test-result.md | 27 --------------------- 3 files changed, 6 insertions(+), 42 deletions(-) diff --git a/loadtest/scripts/collect-server-baseline.sh b/loadtest/scripts/collect-server-baseline.sh index 757ed9d..25ff404 100644 --- a/loadtest/scripts/collect-server-baseline.sh +++ b/loadtest/scripts/collect-server-baseline.sh @@ -1,21 +1,14 @@ #!/usr/bin/env bash set -euo pipefail -if [[ $# -lt 2 || $# -gt 4 ]]; then - echo "usage: $0 OUTPUT_DIR DURATION_SECONDS [INTERVAL_SECONDS] [MODE]" >&2 +if [[ $# -lt 2 || $# -gt 3 ]]; then + echo "usage: $0 OUTPUT_DIR DURATION_SECONDS [INTERVAL_SECONDS]" >&2 exit 2 fi output_dir=$1 duration_seconds=$2 interval_seconds=${3:-1} -mode=${4:-real} -wait_timeout_seconds=${WAIT_TIMEOUT_SECONDS:-3600} - -if [[ "$mode" != "bypass" && "$mode" != "fixed_delay" && "$mode" != "real" ]]; then - echo "mode must be bypass, fixed_delay, or real" >&2 - exit 2 -fi mkdir -p "$output_dir" @@ -25,8 +18,8 @@ if [[ -z "$server_pid" ]]; then exit 1 fi -echo "waiting for an active WebRTC session (timeout: ${wait_timeout_seconds}s)" -wait_deadline=$((SECONDS + wait_timeout_seconds)) +echo "waiting for an active WebRTC session (timeout: 600s)" +wait_deadline=$((SECONDS + 600)) while ((SECONDS < wait_deadline)); do active_sessions=$(curl --fail --silent http://127.0.0.1:8000/metrics | awk '$1 == "innolive_active_sessions" { print $2; exit }') @@ -46,7 +39,7 @@ curl --fail --silent http://127.0.0.1:8000/metrics > "$output_dir/metrics-initia curl --fail --silent http://127.0.0.1:8000/health > "$output_dir/health-initial.json" echo "timestamp,gpu_util_pct,gpu_memory_mib" > "$output_dir/gpu.csv" -echo "timestamp,mode,active_sessions,queue_size,received,processed,dropped,failures" > "$output_dir/app.csv" +echo "timestamp,active_sessions,queue_size,received_real,processed_real,dropped_real,failures_real" > "$output_dir/app.csv" pidstat -h -r -u -p "$server_pid" "$interval_seconds" "$duration_seconds" > "$output_dir/pidstat.txt" & pidstat_pid=$! @@ -66,7 +59,7 @@ for ((sample = 0; sample < samples; sample++)); do value=$(awk -v pattern="$pattern" '$0 ~ pattern { print $NF; exit }' <<< "$metrics") echo "${value:-0}" } - echo "$timestamp,$mode,$(metric_value '^innolive_active_sessions '),$(metric_value '^innolive_processing_queue_size '),$(metric_value "^innolive_frame_received_total\\{mode=\"$mode\"\\} "),$(metric_value "^innolive_frame_processed_total\\{mode=\"$mode\"\\} "),$(metric_value "^innolive_frame_dropped_total\\{mode=\"$mode\"\\} "),$(metric_value "^innolive_frame_failures_total\\{mode=\"$mode\"\\} ")" >> "$output_dir/app.csv" + echo "$timestamp,$(metric_value '^innolive_active_sessions '),$(metric_value '^innolive_processing_queue_size '),$(metric_value '^innolive_frame_received_total\\{mode="real"\\} '),$(metric_value '^innolive_frame_processed_total\\{mode="real"\\} '),$(metric_value '^innolive_frame_dropped_total\\{mode="real"\\} '),$(metric_value '^innolive_frame_failures_total\\{mode="real"\\} ')" >> "$output_dir/app.csv" sleep "$interval_seconds" done diff --git a/test-docs/test-backlog.md b/test-docs/test-backlog.md index 919a2d7..4e6507a 100644 --- a/test-docs/test-backlog.md +++ b/test-docs/test-backlog.md @@ -31,8 +31,6 @@ 합격 기준은 720p 30FPS 입력의 90%인 27FPS입니다. -- [x] bypass 단일 세션 5분 Python/aiortc 기준선 측정 -- [x] bypass CPU/RSS 및 `py-spy` 프로파일 수집 - [x] `quantize=16` 적용 후 실제 GPU 예열 시험 - [x] 예열 후 단일 세션 5분 기준선 시험 - [x] 첫 30초를 제외한 수신·서버 처리 FPS 평균 및 p95 계산 diff --git a/test-docs/test-result.md b/test-docs/test-result.md index b50c13e..09a8921 100644 --- a/test-docs/test-result.md +++ b/test-docs/test-result.md @@ -41,33 +41,6 @@ 매 프레임 `half` 폐기 경고를 출력했으므로 `quantize=16` 적용 후 5분 기준선을 다시 측정합니다. -## Python bypass 단일 세션 5분 기준선 - -2026-07-16에 AI 처리를 제외한 Python/aiortc 미디어 경로를 측정했습니다. 첫 30초를 -제외한 270초 클라이언트 결과와 동일 조건 재실행의 서버 자원 결과를 결합했습니다. - -| 항목 | 결과 | 합격 여부 | -| --- | ---: | --- | -| 평균 송신 FPS | 30.00 | 참고값 | -| 평균 수신 FPS | 29.66 | 통과 | -| 수신 FPS p95 | 30.40 | 참고값 | -| 송수신 프레임 차이 | 1.15% | 통과 | -| RTP 패킷 손실 | 0% | 통과 | -| RTT 평균 / p95 | 18.39 / 111.45ms | 참고값 | -| jitter 평균 / p95 | 3.37 / 10.44ms | 참고값 | -| 서버 전체 실행 수신 / 처리 | 8,935 / 8,935 | 통과 | -| 서버 stale drop | 52 (약 0.58%) | 통과 | -| 서버 처리 오류 | 0 | 통과 | -| 평균/최대 CPU | 78.77% / 96% | 참고값 | -| 평균/최대 RSS | 852.60 / 852.98MiB | 참고값 | -| 종료 후 활성 세션/큐 | 0 / 0 | 통과 | - -판정은 **bypass 단일 세션 통과**입니다. 같은 입력에서 real은 약 25.66FPS와 -14.48% 프레임 차이를 보였으므로 네트워크나 기본 WebRTC 전달보다 AI detection·blur -경로가 단일 세션 목표 미달의 주원인입니다. bypass의 `py-spy` 인코딩 worker 표본에서는 -`encode`가 52.3%로 가장 큰 실행 지점이었으며, 이는 향후 Go/Pion bypass 비교에서 -CPU 차이를 설명하는 핵심 지표입니다. - ## Python 단일 세션 5분 기준선 첫 30초는 안정화 구간으로 제외하고 이후 4분 30초를 집계합니다. From b081373f8a0e36fe9b6c05338dce77479b787b40 Mon Sep 17 00:00:00 2001 From: Finefinee Date: Thu, 16 Jul 2026 22:08:27 +0900 Subject: [PATCH 07/11] Align load-test plan and backlog --- test-docs/test-backlog.md | 59 ++++++++++++++++++++++++++++----------- test-docs/test-plan.md | 17 +++++++---- test-docs/test-result.md | 27 ++++++++++++++++++ test-docs/test-runbook.md | 37 ++++++++++++++++++++++++ 4 files changed, 117 insertions(+), 23 deletions(-) diff --git a/test-docs/test-backlog.md b/test-docs/test-backlog.md index 4e6507a..c5377bd 100644 --- a/test-docs/test-backlog.md +++ b/test-docs/test-backlog.md @@ -9,7 +9,7 @@ - [x] 720p 30FPS VP8/IVF 기준 영상 1종 준비 - [x] Windows/WSL2 Ubuntu 24.04 서버 환경 구성 - [x] RTX 3060 Ti CUDA PyTorch 실제 연산 확인 -- [x] Python 전체 테스트 통과: 278개 +- [x] Python 전체 테스트 통과: 279개 - [x] WSL mirrored networking 적용 - [x] 맥북과 Linux 서버 간 최초 WebRTC 미디어 연결 @@ -31,6 +31,8 @@ 합격 기준은 720p 30FPS 입력의 90%인 27FPS입니다. +- [x] bypass 단일 세션 5분 Python/aiortc 기준선 측정 +- [x] bypass CPU/RSS 및 `py-spy` 프로파일 수집 - [x] `quantize=16` 적용 후 실제 GPU 예열 시험 - [x] 예열 후 단일 세션 5분 기준선 시험 - [x] 첫 30초를 제외한 수신·서버 처리 FPS 평균 및 p95 계산 @@ -41,19 +43,47 @@ 2026-07-13 기준선은 수신 25.51FPS, 서버 처리 25.52FPS, 송수신 프레임 차이 14.98%, stale drop 17.47%로 실패했습니다. 따라서 다중 세션 시험은 보류합니다. +2026-07-16 bypass 기준선은 수신 29.66FPS, 송수신 프레임 차이 1.15%로 +통과했습니다. 기본 WebRTC 경로는 단일 720p30 스트림을 처리할 수 있으므로 real +모드의 detection·blur 경로를 우선 최적화합니다. + 단일 세션에서 평균 27FPS 또는 드롭률 10% 기준을 만족하지 못하면 다중 세션 시험을 중단하고 decode·AI·blur·encode 단계별 병목 계측을 먼저 구현합니다. -## 우선순위 3: 계단식·지속·스파이크 부하 +## 우선순위 3: real 단일 세션 최적화 + +현재 최우선 실행 항목입니다. + +- [ ] detection을 전처리·추론·후처리로 세분화 +- [ ] detection 호출과 CPU/GPU 동기화 최적화 +- [ ] blur를 마스크 생성·Gaussian 연산·CPU 반환으로 세분화 +- [ ] blur 연산 최적화 +- [ ] 최적화 후 예열 및 real 단일 세션 5분 재시험 +- [ ] 평균 수신·서버 처리 27FPS와 드롭 10% 기준 재판정 +- [ ] identity exclusion 활성 조건에서 identity 계측 실제 샘플 검증 + +## 우선순위 4: Python/Go 언어 비교 기준선 + +- [ ] Python fixed_delay 20ms 단일 세션 5분 기준선 +- [ ] Python bypass·fixed_delay 조건을 최소 3회 반복해 편차 기록 +- [ ] 동일 REST·WebSocket·WebRTC 계약의 Go/Pion 서버 구현 +- [ ] Go bypass 단일 세션 5분 기준선 +- [ ] Go fixed_delay 20ms 단일 세션 5분 기준선 +- [ ] 양쪽의 FPS·드롭·CPU·RSS·연결 성공률 비교 +- [ ] 동일 AI 모델·정책을 적용할 수 있으면 Go real 기준선 비교 -단일 세션 기준선을 통과한 뒤 진행합니다. +AI 의미가 다르면 결과를 순수 언어 비교가 아닌 전체 구현 비교로 표시합니다. + +## 우선순위 5: 계단식·지속·스파이크 부하 + +각 구현이 해당 모드의 단일 세션 기준선을 통과한 뒤 진행합니다. - [ ] `1 → 2 → 4 → 8` 세션을 각 5분 유지 - [ ] 최초 기준 위반 단계 주변을 1세션 단위로 재시험 - [ ] 최대 안정 세션의 70~80%로 1~2시간 지속 시험 - [ ] `0 → 최대 후보 → 0 → 최대 후보` 스파이크·재연결 시험 -## 우선순위 4: End-to-End 지연 측정 +## 우선순위 6: End-to-End 지연 측정 현재 서버 내부 프레임 처리 시간만 측정합니다. 송신부터 처리 영상 수신까지 측정하려면 다음 중 하나가 필요합니다. @@ -63,7 +93,7 @@ 첫 구현은 대표 클라이언트 1~3개가 타임코드 영상을 판독하는 방식으로 진행합니다. -## 우선순위 5: 단계별 처리 지연 +## 완료된 기반 작업: 단계별 처리 지연 병목 분석을 위해 전체 프레임 처리 시간을 다음 단계로 분리해야 합니다. @@ -75,14 +105,14 @@ - [x] encode 입력 프레임 생성 계측 - [x] 계측 적용 서버를 재시작하고 예열 후 단일 세션 5분 재프로파일 - [x] 단계별 평균과 p95를 비교해 최우선 최적화 대상 선정 -- [ ] detection 호출·전처리·추론·후처리를 세분화하고 최적화 -- [ ] blur 마스크 생성·Gaussian 연산·CPU 반환을 세분화하고 최적화 -- [ ] 최적화 후 단일 세션 5분 기준선 재시험 + +identity 계측 코드는 구현됐지만 재프로파일 당시 identity exclusion이 비활성이라 실제 +identity 샘플은 아직 검증하지 않았습니다. 현재 `encode` 단계는 BGR 배열을 AV 프레임으로 만드는 시간입니다. aiortc 내부 VP8 코덱 압축 시간은 포함하지 않으며, 필요하면 aiortc encoder 계층에 별도 훅을 추가합니다. -## 우선순위 6: 모니터링 스택 검증 +## 우선순위 7: 모니터링 스택 검증 Docker Desktop 또는 실제 Linux Docker 환경에서 다음 스택을 기동하고 검증합니다. @@ -93,13 +123,13 @@ docker compose -f docker-compose.test.yml up -d - Prometheus: - Grafana: (`admin` / `admin`) -## 우선순위 7: 시스템·GPU 자원 메트릭 +## 우선순위 8: 시스템·GPU 자원 메트릭 - CPU/RAM: Node Exporter - 컨테이너: cAdvisor - GPU/VRAM: DCGM Exporter 또는 `nvidia-smi` 주기 수집기 -## 우선순위 8: 고정 테스트 영상 확대 +## 우선순위 9: 고정 테스트 영상 확대 현재 기준 영상 1종은 준비됐습니다. 본 시험에는 다음 3종을 고정해야 합니다. @@ -107,7 +137,7 @@ docker compose -f docker-compose.test.yml up -d - 얼굴 4명, 움직임 보통 - 얼굴 다수, 움직임 많음 -## 우선순위 9: 네트워크 장애 자동화 +## 우선순위 10: 네트워크 장애 자동화 `tc netem` 시나리오와 자동 복구 스크립트가 필요합니다. @@ -116,8 +146,3 @@ docker compose -f docker-compose.test.yml up -d - 손실 1/3/5% - 3초 단절 - 테스트 종료 시 자동 복구 - -## 우선순위 10: Go 서버 비교 구현 - -현재 저장소는 Python 서버입니다. Python 기준선 시험을 완료한 뒤 동일 API와 AI 의미를 -갖는 Go 서버를 구현해야 최종 언어 비교가 가능합니다. diff --git a/test-docs/test-plan.md b/test-docs/test-plan.md index af22f8e..4ea6338 100644 --- a/test-docs/test-plan.md +++ b/test-docs/test-plan.md @@ -504,21 +504,26 @@ A와 C 모두 Go가 크게 빠름 --- -# 8. 권장 성능 합격 기준 +# 8. 확정 성능 합격 기준 -수치는 현재 단일 세션 측정 결과에 맞게 조정해야 하지만, 초기 기준은 다음처럼 잡을 수 있다. +2026-07-13 단일 세션 기준선부터 다음 기준을 공통으로 사용한다. 720p 30FPS 입력의 +90%인 27FPS를 최소 처리량으로 두고, 이와 일치하도록 프레임 손실·드롭 허용치를 +10%로 고정한다. | 항목 | 예시 기준 | | --- | --- | | 연결 성공률 | 99% 이상 | -| 처리 FPS | 목표 FPS의 95% 이상 | -| 프레임 드롭률 | 1% 미만 | -| 서버 오류율 | 0.1% 미만 | +| 클라이언트 수신 FPS | 평균 27FPS 이상 | +| 서버 처리 FPS | 평균 27FPS 이상 | +| 프레임 손실·드롭 | 10% 이하 | +| 서버 오류율 | 0.1% 이하 | | End-to-End 지연 | 제품 목표 이하 | | 메모리 증가 | 지속 테스트 중 안정화 | | 처리 큐 | 지속적으로 증가하지 않음 | | 비식별화 정확도 | 기존 Python보다 감소하지 않음 | +E2E p50/p95/p99는 타임코드 또는 RTP 메타데이터 측정 구현 전까지 판정에서 보류한다. + InnoLive가 “초저지연”을 핵심 가치로 내세우고 있으므로, 단순히 서버가 죽지 않는지를 기준으로 하면 안 된다. 지연과 프레임 드롭, 비식별화 정상 동작을 모두 만족해야 수용 가능한 세션으로 계산해야 한다. 최종 수용량은 다음처럼 정의하는 게 좋다. @@ -634,4 +639,4 @@ Python 분석: py-spy / cProfile Go 분석: pprof ``` -이 방식이면 **“현재 서버가 몇 명을 처리하는가”와 “Go로 전환했을 때 실제로 몇 배 개선되는가”를 동일한 실험에서 함께 증명**할 수 있다. \ No newline at end of file +이 방식이면 **“현재 서버가 몇 명을 처리하는가”와 “Go로 전환했을 때 실제로 몇 배 개선되는가”를 동일한 실험에서 함께 증명**할 수 있다. diff --git a/test-docs/test-result.md b/test-docs/test-result.md index 09a8921..a6042e5 100644 --- a/test-docs/test-result.md +++ b/test-docs/test-result.md @@ -41,6 +41,33 @@ 매 프레임 `half` 폐기 경고를 출력했으므로 `quantize=16` 적용 후 5분 기준선을 다시 측정합니다. +## Python bypass 단일 세션 5분 기준선 + +2026-07-16에 AI를 제외한 Python/aiortc 미디어 경로를 측정했습니다. 첫 실행의 +클라이언트 결과와 동일 조건 재실행의 서버 자원 결과를 사용했습니다. 각 실행은 5분이고 +클라이언트 FPS 통계와 서버 CPU/RSS 통계에서 첫 30초를 제외했습니다. + +| 항목 | 결과 | 합격 여부 | +| --- | ---: | --- | +| 평균 송신 FPS | 30.00 | 참고값 | +| 평균 수신 FPS | 29.66 | 통과 | +| 수신 FPS p95 | 30.40 | 참고값 | +| 송수신 프레임 차이 | 1.15% | 통과 | +| RTP 패킷 손실 | 0% | 통과 | +| RTT 평균 / p95 | 18.39 / 111.45ms | 참고값 | +| jitter 평균 / p95 | 3.37 / 10.44ms | 참고값 | +| 서버 재실행 전체 수신 / 처리 | 8,935 / 8,935 | 통과 | +| 서버 stale drop | 52 (약 0.58%) | 통과 | +| 서버 처리 오류 | 0 | 통과 | +| 평균/최대 CPU | 78.77% / 96% | 참고값 | +| 평균/최대 RSS | 852.60 / 852.98MiB | 참고값 | +| 종료 후 활성 세션/큐 | 0 / 0 | 통과 | + +판정은 **bypass 단일 세션 통과**입니다. 같은 입력의 real 재프로파일은 25.66FPS와 +14.48% 프레임 차이를 보였으므로 기본 WebRTC·네트워크 경로보다 AI detection·blur가 +단일 세션 목표 미달의 주원인입니다. bypass `py-spy`의 주요 인코딩 worker 표본에서는 +`encode`가 52.3%로 나타났으며 향후 Go/Pion bypass CPU 비교에 사용합니다. + ## Python 단일 세션 5분 기준선 첫 30초는 안정화 구간으로 제외하고 이후 4분 30초를 집계합니다. diff --git a/test-docs/test-runbook.md b/test-docs/test-runbook.md index e1cd1fb..43b7438 100644 --- a/test-docs/test-runbook.md +++ b/test-docs/test-runbook.md @@ -31,6 +31,43 @@ docker compose -f docker-compose.test.yml up -d - Prometheus: - Grafana: (`admin` / `admin`) +## Baseline measurement rules + +- Use `720p30.ivf` and one client for the single-session baseline. +- Run each measured condition for five minutes. +- Exclude the first 30 seconds and aggregate the remaining 270 seconds. +- For `real`, run a separate warm-up client first and exclude it from results. +- Accept only when average client receive FPS and server process FPS are at + least 27, frame loss/drop is at most 10%, connection success is at least + 99%, and server processing errors are at most 0.1%. +- E2E p50/p95/p99 remain deferred until timecode or RTP metadata measurement + is implemented. + +Collect server-side CPU, RSS, GPU, Prometheus snapshots, and a `py-spy` profile: + +```bash +bash loadtest/scripts/collect-server-baseline.sh \ + loadtest/server-results/real-single-5m 300 1 +``` + +The current collector waits up to 600 seconds for a session and writes +per-second frame columns for `real` mode. Its initial/final Prometheus snapshots +still contain every mode, so use their counter delta for `bypass` and +`fixed_delay`. Start the client within the wait window. + +After each run, verify `GET /sessions` returns no sessions and `/health` reports +AI queue depths of zero. + +## External Windows/WSL testing + +- Do not use `127.0.0.1` from the load-generator Mac; use the Windows server's + current public or LAN IP. +- Set `WEBRTC_ANNOUNCED_IP` to the same reachable server address. +- Allow TCP 8000 and UDP 50000-60000 only from the Mac's current public IP. +- Recheck both public IPs after a router reconnect, VPN/WARP change, or reboot. +- WSL2 mirrored networking also requires the supplied Windows/Hyper-V firewall + script to be run from an elevated PowerShell. + ## Control-plane load The k6 script measures REST session lifecycle load only. It does not generate From cf66d8e6e12038b665f707d5e2ed1927cdb6ee76 Mon Sep 17 00:00:00 2001 From: Finefinee Date: Fri, 17 Jul 2026 02:44:01 +0900 Subject: [PATCH 08/11] test: document fixed-delay load baseline --- loadtest/scripts/collect-server-baseline.sh | 21 +++-- loadtest/windows/collect-server-metrics.ps1 | 95 +++++++++++++++++++++ test-docs/test-backlog.md | 13 ++- test-docs/test-plan.md | 4 + test-docs/test-result.md | 45 +++++++++- test-docs/test-runbook.md | 31 +++++++ 6 files changed, 198 insertions(+), 11 deletions(-) create mode 100644 loadtest/windows/collect-server-metrics.ps1 diff --git a/loadtest/scripts/collect-server-baseline.sh b/loadtest/scripts/collect-server-baseline.sh index 25ff404..5dd45a5 100644 --- a/loadtest/scripts/collect-server-baseline.sh +++ b/loadtest/scripts/collect-server-baseline.sh @@ -1,14 +1,20 @@ #!/usr/bin/env bash set -euo pipefail -if [[ $# -lt 2 || $# -gt 3 ]]; then - echo "usage: $0 OUTPUT_DIR DURATION_SECONDS [INTERVAL_SECONDS]" >&2 +if [[ $# -lt 2 || $# -gt 4 ]]; then + echo "usage: $0 OUTPUT_DIR DURATION_SECONDS [INTERVAL_SECONDS] [ENABLE_PY_SPY]" >&2 exit 2 fi output_dir=$1 duration_seconds=$2 interval_seconds=${3:-1} +enable_py_spy=${4:-true} + +if [[ "$enable_py_spy" != "true" && "$enable_py_spy" != "false" ]]; then + echo "ENABLE_PY_SPY must be true or false" >&2 + exit 2 +fi mkdir -p "$output_dir" @@ -43,8 +49,11 @@ echo "timestamp,active_sessions,queue_size,received_real,processed_real,dropped_ pidstat -h -r -u -p "$server_pid" "$interval_seconds" "$duration_seconds" > "$output_dir/pidstat.txt" & pidstat_pid=$! -/opt/inno-live-venv/bin/py-spy record --pid "$server_pid" --duration "$duration_seconds" --rate 50 --format speedscope --output "$output_dir/py-spy-speedscope.json" & -py_spy_pid=$! +py_spy_pid="" +if [[ "$enable_py_spy" == "true" ]]; then + /opt/inno-live-venv/bin/py-spy record --pid "$server_pid" --duration "$duration_seconds" --rate 50 --format speedscope --output "$output_dir/py-spy-speedscope.json" & + py_spy_pid=$! +fi samples=$((duration_seconds / interval_seconds)) for ((sample = 0; sample < samples; sample++)); do @@ -65,7 +74,9 @@ for ((sample = 0; sample < samples; sample++)); do done wait "$pidstat_pid" -wait "$py_spy_pid" +if [[ -n "$py_spy_pid" ]]; then + wait "$py_spy_pid" +fi curl --fail --silent http://127.0.0.1:8000/health > "$output_dir/health-final.json" curl --fail --silent http://127.0.0.1:8000/sessions > "$output_dir/sessions-final.json" curl --fail --silent http://127.0.0.1:8000/metrics > "$output_dir/metrics-final.prom" diff --git a/loadtest/windows/collect-server-metrics.ps1 b/loadtest/windows/collect-server-metrics.ps1 new file mode 100644 index 0000000..ea75d83 --- /dev/null +++ b/loadtest/windows/collect-server-metrics.ps1 @@ -0,0 +1,95 @@ +param( + [Parameter(Mandatory = $true)] + [string]$OutputDirectory, + + [int]$DurationSeconds = 360, + [int]$IntervalSeconds = 1, + [int]$WaitTimeoutSeconds = 600, + [ValidateSet("bypass", "fixed_delay", "real")] + [string]$Mode = "fixed_delay", + [string]$BaseUrl = "http://127.0.0.1:8000" +) + +$ErrorActionPreference = "Stop" + +function Get-MetricsText { + (Invoke-WebRequest -UseBasicParsing -Uri "$BaseUrl/metrics" -TimeoutSec 5).Content +} + +function Get-MetricValue { + param( + [string]$Text, + [string]$Name, + [string]$Labels = "" + ) + + $escapedName = [regex]::Escape($Name) + $escapedLabels = [regex]::Escape($Labels) + $pattern = if ($Labels) { + "(?m)^${escapedName}\{${escapedLabels}\} ([0-9.eE+-]+)$" + } else { + "(?m)^${escapedName} ([0-9.eE+-]+)$" + } + + $match = [regex]::Match($Text, $pattern) + if ($match.Success) { + return [double]$match.Groups[1].Value + } + return 0.0 +} + +New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null +$initialMetricsPath = Join-Path $OutputDirectory "metrics-initial.prom" +$finalMetricsPath = Join-Path $OutputDirectory "metrics-final.prom" +$samplesPath = Join-Path $OutputDirectory "app.csv" +$modeLabels = "mode=`"$Mode`"" + +$initialMetrics = Get-MetricsText +$initialMetrics | Set-Content -Encoding utf8 -Path $initialMetricsPath +(Invoke-WebRequest -UseBasicParsing -Uri "$BaseUrl/health" -TimeoutSec 5).Content | + Set-Content -Encoding utf8 -Path (Join-Path $OutputDirectory "health-initial.json") + +"timestamp,active_sessions,queue_size,received_$Mode,processed_$Mode,dropped_$Mode,failures_$Mode,process_cpu_seconds,process_rss_bytes" | + Set-Content -Encoding utf8 -Path $samplesPath + +Write-Host "Waiting for an active WebRTC session (timeout: ${WaitTimeoutSeconds}s)" +$deadline = (Get-Date).AddSeconds($WaitTimeoutSeconds) +do { + $metrics = Get-MetricsText + $active = Get-MetricValue -Text $metrics -Name "innolive_active_sessions" + if ($active -gt 0) { + break + } + if ((Get-Date) -ge $deadline) { + throw "Timed out waiting for an active WebRTC session" + } + Start-Sleep -Seconds 1 +} while ($true) + +Write-Host "Active session detected; collecting for ${DurationSeconds}s" +$end = (Get-Date).AddSeconds($DurationSeconds) +do { + $metrics = Get-MetricsText + $timestamp = (Get-Date).ToString("o") + $active = Get-MetricValue -Text $metrics -Name "innolive_active_sessions" + $queue = Get-MetricValue -Text $metrics -Name "innolive_processing_queue_size" + $received = Get-MetricValue -Text $metrics -Name "innolive_frame_received_total" -Labels $modeLabels + $processed = Get-MetricValue -Text $metrics -Name "innolive_frame_processed_total" -Labels $modeLabels + $dropped = Get-MetricValue -Text $metrics -Name "innolive_frame_dropped_total" -Labels $modeLabels + $failures = Get-MetricValue -Text $metrics -Name "innolive_frame_failures_total" -Labels $modeLabels + $cpu = Get-MetricValue -Text $metrics -Name "process_cpu_seconds_total" + $rss = Get-MetricValue -Text $metrics -Name "process_resident_memory_bytes" + + "$timestamp,$active,$queue,$received,$processed,$dropped,$failures,$cpu,$rss" | + Add-Content -Encoding utf8 -Path $samplesPath + Start-Sleep -Seconds $IntervalSeconds +} while ((Get-Date) -lt $end) + +$finalMetrics = Get-MetricsText +$finalMetrics | Set-Content -Encoding utf8 -Path $finalMetricsPath +(Invoke-WebRequest -UseBasicParsing -Uri "$BaseUrl/health" -TimeoutSec 5).Content | + Set-Content -Encoding utf8 -Path (Join-Path $OutputDirectory "health-final.json") +(Invoke-WebRequest -UseBasicParsing -Uri "$BaseUrl/sessions" -TimeoutSec 5).Content | + Set-Content -Encoding utf8 -Path (Join-Path $OutputDirectory "sessions-final.json") + +Write-Host "Server metrics saved to $OutputDirectory" diff --git a/test-docs/test-backlog.md b/test-docs/test-backlog.md index c5377bd..3640ece 100644 --- a/test-docs/test-backlog.md +++ b/test-docs/test-backlog.md @@ -12,6 +12,8 @@ - [x] Python 전체 테스트 통과: 279개 - [x] WSL mirrored networking 적용 - [x] 맥북과 Linux 서버 간 최초 WebRTC 미디어 연결 +- [x] Python `fixed_delay 20ms` 1·2·4·6·7·8세션 5분 시험 +- [x] Windows 호스트용 Prometheus 서버 메트릭 수집기 추가 맥북 공인 IP로 범위를 제한한 Windows 방화벽 규칙을 적용했고, `bypass` 모드에서 30초 원격 미디어 smoke test에 성공했습니다. ICE는 `completed`, PeerConnection은 @@ -52,7 +54,7 @@ ## 우선순위 3: real 단일 세션 최적화 -현재 최우선 실행 항목입니다. +언어 선택 기준선 확정을 우선하므로 현재는 보류합니다. - [ ] detection을 전처리·추론·후처리로 세분화 - [ ] detection 호출과 CPU/GPU 동기화 최적화 @@ -64,8 +66,10 @@ ## 우선순위 4: Python/Go 언어 비교 기준선 -- [ ] Python fixed_delay 20ms 단일 세션 5분 기준선 -- [ ] Python bypass·fixed_delay 조건을 최소 3회 반복해 편차 기록 +- [x] Python fixed_delay 20ms 단일 세션 5분 기준선 +- [x] Python fixed_delay 20ms 다중 세션 반복 및 최소 8세션 처리 확인 +- [ ] Python bypass `1 → 2 → 4 → 8` 세션 5분 기준선 +- [ ] Python bypass 조건을 반복해 편차 기록 - [ ] 동일 REST·WebSocket·WebRTC 계약의 Go/Pion 서버 구현 - [ ] Go bypass 단일 세션 5분 기준선 - [ ] Go fixed_delay 20ms 단일 세션 5분 기준선 @@ -78,7 +82,8 @@ AI 의미가 다르면 결과를 순수 언어 비교가 아닌 전체 구현 각 구현이 해당 모드의 단일 세션 기준선을 통과한 뒤 진행합니다. -- [ ] `1 → 2 → 4 → 8` 세션을 각 5분 유지 +- [x] Python fixed_delay에서 `1 → 2 → 4 → 8` 세션을 각 5분 유지 +- [ ] Python bypass와 Go 구현에서 `1 → 2 → 4 → 8` 세션을 각 5분 유지 - [ ] 최초 기준 위반 단계 주변을 1세션 단위로 재시험 - [ ] 최대 안정 세션의 70~80%로 1~2시간 지속 시험 - [ ] `0 → 최대 후보 → 0 → 최대 후보` 스파이크·재연결 시험 diff --git a/test-docs/test-plan.md b/test-docs/test-plan.md index 4ea6338..c6482e6 100644 --- a/test-docs/test-plan.md +++ b/test-docs/test-plan.md @@ -524,6 +524,10 @@ A와 C 모두 Go가 크게 빠름 E2E p50/p95/p99는 타임코드 또는 RTP 메타데이터 측정 구현 전까지 판정에서 보류한다. +`1 → 2 → 4 → 8`은 최대 세션 수를 미리 8로 제한하는 값이 아니라 한계 구간을 빠르게 +찾기 위한 계단식 체크포인트다. 8세션이 통과하면 결과는 “최소 8세션”으로 기록하고, +최대치가 필요한 경우 12·16세션으로 확대한 뒤 최초 실패 구간을 1세션 단위로 좁힌다. + InnoLive가 “초저지연”을 핵심 가치로 내세우고 있으므로, 단순히 서버가 죽지 않는지를 기준으로 하면 안 된다. 지연과 프레임 드롭, 비식별화 정상 동작을 모두 만족해야 수용 가능한 세션으로 계산해야 한다. 최종 수용량은 다음처럼 정의하는 게 좋다. diff --git a/test-docs/test-result.md b/test-docs/test-result.md index a6042e5..545891a 100644 --- a/test-docs/test-result.md +++ b/test-docs/test-result.md @@ -68,6 +68,47 @@ 단일 세션 목표 미달의 주원인입니다. bypass `py-spy`의 주요 인코딩 worker 표본에서는 `encode`가 52.3%로 나타났으며 향후 Go/Pion bypass CPU 비교에 사용합니다. +## Python fixed_delay 20ms 다중 세션 예비 용량 + +2026-07-16~17에 같은 Python/aiortc 서버 프로세스와 `720p30.ivf`를 사용해 +`fixed_delay 20ms`를 1·2·4·6·7·8세션으로 측정했습니다. 각 실행은 5분이며 첫 +30초 이후의 클라이언트 표본을 집계했습니다. 아래 값은 각 세션의 평균 수신 FPS 범위와 +클라이언트 중 가장 큰 송수신 프레임 차이 및 RTT p95입니다. + +| 동시 세션 | 평균 수신 FPS 범위 | 최대 프레임 차이 | RTT p95 최대 | 연결 유지 | 판정 | +| ---: | ---: | ---: | ---: | ---: | --- | +| 1 | 29.61 | 1.31% | 32.71ms | 1/1 | 통과 | +| 2 | 29.56~29.57 | 1.53% | 34.93ms | 2/2 | 통과 | +| 4 | 28.91~29.18 | 3.66% | 96.76ms | 4/4 | 통과 | +| 6 | 28.98~29.68 | 1.82% | 58.03ms | 6/6 | 통과 | +| 7 | 28.77~29.45 | 3.08% | 26.95ms | 7/7 | 통과 | +| 8 | 28.18~28.74 | 4.98% | 41.47ms | 8/8 | 통과 | + +최종 8세션 실행에서는 Windows 호스트 수집기로 서버 메트릭도 함께 기록했습니다. +8세션이 모두 활성인 303.4초 동안 69,545프레임을 처리해 전체 229.22FPS, 세션당 +28.65FPS를 기록했습니다. stale drop은 3,102 / 69,549(4.46%), 처리 오류는 +0건이었습니다. 평균 CPU는 약 4.13코어, 최대 RSS는 약 1.66GiB였고 처리 큐는 +시험 내내 0이었습니다. 종료 후 활성 세션과 큐도 모두 0이었습니다. + +따라서 5분 예비 용량 시험 기준으로 **Python fixed_delay의 확인 용량은 최소 +8세션**입니다. 8은 계단식 시험 체크포인트이며 최대치가 아닙니다. 최종 최대 안정 +세션을 주장하려면 더 높은 부하에서 경계를 찾고 후보 세션 수를 30분 이상 유지해야 +합니다. + +### 비단조 실패 실행과 네트워크 판정 + +같은 시험 과정에서 5·6세션이 끊기고 ICE 로그에 `Consent to send expired`가 기록된 +실행들이 있었습니다. 그러나 8세션 성공 뒤 더 낮은 세션 수가 실패했고, 서버 재시작 +직후에도 실패했으며, profiler를 끈 실행도 실패했습니다. 이후 같은 서버에서 6세션이 +연속으로 성공했고 7세션 종료 2.884초 뒤 시작한 8세션도 통과했습니다. health/ping +진단 트래픽을 끈 6·7·8세션도 모두 통과했습니다. + +이 비단조 실패는 서버의 고정 용량 경계, 누적 상태, profiler 부하 또는 cooldown으로 +설명되지 않습니다. 현재 가장 가능성 높은 원인은 공인 인터넷 구간의 일시적인 WebRTC +UDP/NAT 경로 이상입니다. 실패 원본은 네트워크 장애 사례로 보존하되 언어별 서버 용량 +판정값에는 포함하지 않습니다. 최종 용량 비교는 가능하면 같은 LAN 또는 통제된 TURN +경로에서 반복합니다. + ## Python 단일 세션 5분 기준선 첫 30초는 안정화 구간으로 제외하고 이후 4분 30초를 집계합니다. @@ -144,8 +185,8 @@ CPU/GPU 동기화 비용의 영향이 큰 상태입니다. | 항목 | Python | Go | 차이 | | --- | --- | --- | --- | -| 최대 연결 가능 세션 | | | | -| 최대 안정 세션 | | | | +| 최대 연결 가능 세션 | 최소 8 (fixed_delay 예비 시험) | | | +| 최대 안정 세션 | 미확정 (30분 지속 시험 전) | | | | 운영 권장 세션 | | | | | 평균 처리 FPS | | | | | 프레임 드롭률 | | | | diff --git a/test-docs/test-runbook.md b/test-docs/test-runbook.md index 43b7438..7b88d96 100644 --- a/test-docs/test-runbook.md +++ b/test-docs/test-runbook.md @@ -50,11 +50,37 @@ bash loadtest/scripts/collect-server-baseline.sh \ loadtest/server-results/real-single-5m 300 1 ``` +Pass `false` as the fourth argument when a profile is not needed: + +```bash +bash loadtest/scripts/collect-server-baseline.sh \ + loadtest/server-results/fixed-delay-8 360 1 false +``` + The current collector waits up to 600 seconds for a session and writes per-second frame columns for `real` mode. Its initial/final Prometheus snapshots still contain every mode, so use their counter delta for `bypass` and `fixed_delay`. Start the client within the wait window. +When the server is reachable from Windows at `127.0.0.1:8000`, collect the +same Prometheus counters plus process CPU time and RSS from PowerShell: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass ` + -File ".\loadtest\windows\collect-server-metrics.ps1" ` + -OutputDirectory ".\loadtest\server-results\fixed-delay-8" ` + -DurationSeconds 360 -IntervalSeconds 1 ` + -Mode fixed_delay +``` + +The Windows collector supports `bypass`, `fixed_delay`, and `real`. It waits +up to 600 seconds for the first active session, then saves per-second counters, +initial/final Prometheus snapshots, health, and final session cleanup state. + +Judge client send/receive frame gap and server stale drop independently. They +observe overlapping loss in different parts of the pipeline and must not be +added together. + After each run, verify `GET /sessions` returns no sessions and `/health` reports AI queue depths of zero. @@ -67,6 +93,11 @@ AI queue depths of zero. - Recheck both public IPs after a router reconnect, VPN/WARP change, or reboot. - WSL2 mirrored networking also requires the supplied Windows/Hyper-V firewall script to be run from an elevated PowerShell. +- A non-monotonic result such as eight sessions passing while five or six fail, + especially with `Consent to send expired`, is a network-path incident rather + than a server capacity boundary until it reproduces on a controlled LAN or + TURN path. Preserve it separately and repeat the same load without restarting + the server. ## Control-plane load From c25d6f0df5d3891a1c19096cfc4a9590f2b5a3e6 Mon Sep 17 00:00:00 2001 From: Finefinee Date: Fri, 17 Jul 2026 02:52:41 +0900 Subject: [PATCH 09/11] docs: add detailed fixed-delay session results --- .../results/fixed-delay-20ms/1-session.md | 36 ++++++++++ .../results/fixed-delay-20ms/2-sessions.md | 38 +++++++++++ .../results/fixed-delay-20ms/4-sessions.md | 42 ++++++++++++ .../results/fixed-delay-20ms/6-sessions.md | 50 ++++++++++++++ .../results/fixed-delay-20ms/7-sessions.md | 48 ++++++++++++++ .../results/fixed-delay-20ms/8-sessions.md | 66 +++++++++++++++++++ test-docs/test-result.md | 15 +++-- 7 files changed, 289 insertions(+), 6 deletions(-) create mode 100644 test-docs/results/fixed-delay-20ms/1-session.md create mode 100644 test-docs/results/fixed-delay-20ms/2-sessions.md create mode 100644 test-docs/results/fixed-delay-20ms/4-sessions.md create mode 100644 test-docs/results/fixed-delay-20ms/6-sessions.md create mode 100644 test-docs/results/fixed-delay-20ms/7-sessions.md create mode 100644 test-docs/results/fixed-delay-20ms/8-sessions.md diff --git a/test-docs/results/fixed-delay-20ms/1-session.md b/test-docs/results/fixed-delay-20ms/1-session.md new file mode 100644 index 0000000..8b788ac --- /dev/null +++ b/test-docs/results/fixed-delay-20ms/1-session.md @@ -0,0 +1,36 @@ +# Python fixed_delay 20ms — 1세션 + +## 시험 정보 + +- 클라이언트 원본: `baseline-fixed-delay-20ms-5m.json` / `.csv` +- 시작: 2026-07-16 22:29:54 KST +- 종료: 2026-07-16 22:34:59 KST +- 전체 실행 시간: 305.48초 +- 입력: `720p30.ivf` (1280×720, VP8, 30FPS) +- 안정 구간: 최초 30초 이후 표본 +- 결과: **통과** + +## 클라이언트 처리량 + +| 클라이언트 | 평균 수신 FPS | 수신 FPS p5 | 0 제외 최소 FPS | 0 표본 | 송신 프레임 | 수신 프레임 | 프레임 차이 | 손실 RTP 패킷 | RTP 손실률 | +| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| 1 | 29.61 | 28.20 | 24.20 | 0 | 8,999 | 8,881 | 1.31% | 0 | 0.000% | + +## 클라이언트 네트워크 및 연결 상태 + +| 클라이언트 | jitter 평균/p95/최대 | RTT 평균/p95/최대 | 최종 PeerConnection / ICE | +| ---: | ---: | ---: | --- | +| 1 | 3.41 / 5.84 / 20.55ms | 15.88 / 32.71 / 150.99ms | `connected` / `connected` | + +## 서버 수집 결과 + +연결 설정과 종료 정리를 포함해 324초 동안 서버 지표를 수집했습니다. 수신·처리 +프레임은 각각 8,882개, stale drop은 110개(1.24%), 처리 오류는 0건이었고 처리 큐 +최댓값은 0이었습니다. 전체 수집 구간의 CPU 사용량은 약 0.74코어, 종료 시 RSS는 +811.19MiB였으며 최종 세션 목록은 비어 있었습니다. 연결 설정과 종료가 포함된 전체 +구간 FPS는 최초 30초를 제외한 합격 판정에는 사용하지 않았습니다. + +## 판정 + +평균 수신 FPS가 27을 넘고 프레임 차이가 10% 미만이었습니다. 연결이 끝까지 +유지됐으며 서버 처리 오류와 큐 증가도 없었습니다. diff --git a/test-docs/results/fixed-delay-20ms/2-sessions.md b/test-docs/results/fixed-delay-20ms/2-sessions.md new file mode 100644 index 0000000..eb39bfb --- /dev/null +++ b/test-docs/results/fixed-delay-20ms/2-sessions.md @@ -0,0 +1,38 @@ +# Python fixed_delay 20ms — 2세션 + +## 시험 정보 + +- 클라이언트 원본: `fixed-delay-20ms-2-clients-5m.json` / `.csv` +- 시작: 2026-07-16 22:39:35 KST +- 종료: 2026-07-16 22:44:46 KST +- 전체 실행 시간: 310.63초 +- 입력: `720p30.ivf` (1280×720, VP8, 30FPS) +- 안정 구간: 최초 30초 이후 표본 +- 결과: **통과** + +## 클라이언트 처리량 + +| 클라이언트 | 평균 수신 FPS | 수신 FPS p5 | 0 제외 최소 FPS | 0 표본 | 송신 프레임 | 수신 프레임 | 프레임 차이 | 손실 RTP 패킷 | RTP 손실률 | +| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| 1 | 29.57 | 28.80 | 26.60 | 0 | 9,158 | 9,027 | 1.43% | 0 | 0.000% | +| 2 | 29.56 | 27.40 | 26.40 | 0 | 8,999 | 8,861 | 1.53% | 0 | 0.000% | + +## 클라이언트 네트워크 및 연결 상태 + +| 클라이언트 | jitter 평균/p95/최대 | RTT 평균/p95/최대 | 최종 PeerConnection / ICE | +| ---: | ---: | ---: | --- | +| 1 | 3.68 / 9.48 / 13.24ms | 14.01 / 34.93 / 81.63ms | `connected` / `connected` | +| 2 | 4.09 / 12.51 / 23.98ms | 11.69 / 26.09 / 165.37ms | `connected` / `connected` | + +## 서버 수집 결과 + +326초 동안 서버 지표를 수집했습니다. 서버 수신 17,890프레임, 처리 17,889프레임, +stale drop 256개(1.43%), 처리 오류 0건, 처리 큐 최댓값 0을 기록했습니다. 전체 수집 +구간의 CPU 사용량은 약 1.06코어였고 종료 시 RSS는 932.81MiB였습니다. 최종 세션 +목록은 비어 있었습니다. 연결 설정과 종료가 포함되므로 전체 구간 FPS는 참고값으로만 +사용합니다. + +## 판정 + +두 세션 모두 평균 수신 27FPS를 넘고 연결을 유지했으며 최대 프레임 차이는 1.53%로 +10% 기준을 충분히 만족했습니다. diff --git a/test-docs/results/fixed-delay-20ms/4-sessions.md b/test-docs/results/fixed-delay-20ms/4-sessions.md new file mode 100644 index 0000000..49f361f --- /dev/null +++ b/test-docs/results/fixed-delay-20ms/4-sessions.md @@ -0,0 +1,42 @@ +# Python fixed_delay 20ms — 4세션 + +## 시험 정보 + +- 클라이언트 원본: `fixed-delay-20ms-4-clients-5m.json` / `.csv` +- 시작: 2026-07-16 22:49:14 KST +- 종료: 2026-07-16 22:54:35 KST +- 전체 실행 시간: 321.27초 +- 입력: `720p30.ivf` (1280×720, VP8, 30FPS) +- 안정 구간: 최초 30초 이후 표본 +- 결과: **통과** + +## 클라이언트 처리량 + +| 클라이언트 | 평균 수신 FPS | 수신 FPS p5 | 0 제외 최소 FPS | 0 표본 | 송신 프레임 | 수신 프레임 | 프레임 차이 | 손실 RTP 패킷 | RTP 손실률 | +| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| 1 | 29.18 | 28.20 | 26.40 | 0 | 9,477 | 9,212 | 2.80% | 0 | 0.000% | +| 2 | 29.14 | 27.60 | 25.60 | 0 | 9,318 | 9,045 | 2.93% | 0 | 0.000% | +| 3 | 29.00 | 26.80 | 24.80 | 0 | 9,159 | 8,852 | 3.35% | 0 | 0.000% | +| 4 | 28.91 | 26.60 | 22.60 | 0 | 8,999 | 8,670 | 3.66% | 0 | 0.000% | + +## 클라이언트 네트워크 및 연결 상태 + +| 클라이언트 | jitter 평균/p95/최대 | RTT 평균/p95/최대 | 최종 PeerConnection / ICE | +| ---: | ---: | ---: | --- | +| 1 | 4.46 / 10.54 / 17.32ms | 21.53 / 59.62 / 196.69ms | `connected` / `connected` | +| 2 | 4.03 / 8.41 / 15.97ms | 21.29 / 96.76 / 111.40ms | `connected` / `connected` | +| 3 | 4.60 / 9.51 / 16.89ms | 24.17 / 92.93 / 244.00ms | `connected` / `connected` | +| 4 | 4.39 / 8.54 / 12.47ms | 20.30 / 66.82 / 153.63ms | `connected` / `connected` | + +## 서버 수집 결과 + +362초 동안 서버 지표를 수집했습니다. 서버 수신·처리 프레임은 각각 35,783개, +stale drop은 1,141개(3.19%), 처리 오류는 0건이었고 처리 큐 최댓값은 0이었습니다. +전체 수집 구간의 CPU 사용량은 약 1.83코어, 종료 시 RSS는 1,129.31MiB였습니다. +최종 세션 목록은 비어 있었습니다. 연결 설정과 종료가 포함된 전체 구간 FPS는 안정 +구간 판정값이 아닌 참고값입니다. + +## 판정 + +네 세션 모두 평균 수신 27FPS를 넘었습니다. 최대 프레임 차이는 3.66%였고 모든 +PeerConnection과 ICE 상태가 `connected`로 유지됐습니다. diff --git a/test-docs/results/fixed-delay-20ms/6-sessions.md b/test-docs/results/fixed-delay-20ms/6-sessions.md new file mode 100644 index 0000000..68279da --- /dev/null +++ b/test-docs/results/fixed-delay-20ms/6-sessions.md @@ -0,0 +1,50 @@ +# Python fixed_delay 20ms — 6세션 + +## 시험 정보 + +- 클라이언트 원본: `fixed-delay-20ms-6-no-diagnostic-traffic.json` / `.csv` +- 시작: 2026-07-17 02:02:52 KST +- 종료: 2026-07-17 02:08:24 KST +- 전체 실행 시간: 332.25초 +- 입력: `720p30.ivf` (1280×720, VP8, 30FPS) +- 안정 구간: 최초 30초 이후 표본 +- 별도 health/ping 트래픽: 사용하지 않음 +- 결과: **통과** + +## 클라이언트 처리량 + +| 클라이언트 | 평균 수신 FPS | 수신 FPS p5 | 0 제외 최소 FPS | 0 표본 | 송신 프레임 | 수신 프레임 | 프레임 차이 | 손실 RTP 패킷 | RTP 손실률 | +| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| 1 | 29.56 | 28.60 | 26.60 | 0 | 9,803 | 9,662 | 1.44% | 5 | 0.025% | +| 2 | 29.48 | 28.20 | 25.80 | 0 | 9,645 | 9,474 | 1.77% | 2 | 0.010% | +| 3 | 29.60 | 28.80 | 26.00 | 0 | 9,487 | 9,356 | 1.38% | 1 | 0.005% | +| 4 | 29.68 | 29.00 | 28.00 | 0 | 9,316 | 9,214 | 1.09% | 0 | 0.000% | +| 5 | 28.98 | 28.00 | 25.80 | 1 | 9,158 | 8,991 | 1.82% | 0 | 0.000% | +| 6 | 29.02 | 26.80 | 26.40 | 1 | 8,999 | 8,847 | 1.69% | 0 | 0.000% | + +## 클라이언트 네트워크 및 연결 상태 + +| 클라이언트 | jitter 평균/p95/최대 | RTT 평균/p95/최대 | 최종 PeerConnection / ICE | +| ---: | ---: | ---: | --- | +| 1 | 3.19 / 4.88 / 8.20ms | 15.17 / 31.85 / 134.00ms | `connected` / `connected` | +| 2 | 3.19 / 5.09 / 6.70ms | 16.36 / 58.03 / 173.84ms | `connected` / `connected` | +| 3 | 2.98 / 5.32 / 7.46ms | 7.28 / 30.20 / 61.22ms | `connected` / `connected` | +| 4 | 3.12 / 4.81 / 7.00ms | 8.83 / 27.82 / 76.25ms | `connected` / `connected` | +| 5 | 3.51 / 6.05 / 30.62ms | 13.92 / 39.60 / 143.37ms | `connected` / `connected` | +| 6 | 3.06 / 4.67 / 8.21ms | 13.16 / 42.19 / 63.17ms | `connected` / `connected` | + +## 서버 수집 결과 + +이 대표 무진단 실행에는 동일 시점의 서버 자원 수집기를 연결하지 않았습니다. 다른 +6세션 성공 실행에는 서버 카운터가 있지만 서로 다른 실행의 값을 섞지 않기 위해 이 +문서에는 포함하지 않았습니다. + +## 판정 및 반복성 + +여섯 세션이 모두 통과했습니다. 최대 프레임 차이는 1.82%, RTT p95 최댓값은 +58.03ms였고 최종 연결 상태는 모두 `connected`였습니다. 클라이언트 5와 6의 0FPS +표본 1개는 순차 연결 직후의 첫 속도 계산 표본이며 이후 누적 수신 프레임은 계속 +증가했습니다. + +이전에 발생한 비단조 6세션 실패는 상위 결과 문서에서 공인 UDP/NAT 경로 이상으로 +분리했으며 서버 용량 경계로 판정하지 않았습니다. diff --git a/test-docs/results/fixed-delay-20ms/7-sessions.md b/test-docs/results/fixed-delay-20ms/7-sessions.md new file mode 100644 index 0000000..1e3e8ed --- /dev/null +++ b/test-docs/results/fixed-delay-20ms/7-sessions.md @@ -0,0 +1,48 @@ +# Python fixed_delay 20ms — 7세션 + +## 시험 정보 + +- 클라이언트 원본: `fixed-delay-20ms-7-no-diagnostic.json` / `.csv` +- 시작: 2026-07-17 02:12:36 KST +- 종료: 2026-07-17 02:18:13 KST +- 전체 실행 시간: 337.31초 +- 입력: `720p30.ivf` (1280×720, VP8, 30FPS) +- 안정 구간: 최초 30초 이후 표본 +- 별도 health/ping 트래픽: 사용하지 않음 +- 결과: **통과** + +## 클라이언트 처리량 + +| 클라이언트 | 평균 수신 FPS | 수신 FPS p5 | 0 제외 최소 FPS | 0 표본 | 송신 프레임 | 수신 프레임 | 프레임 차이 | 손실 RTP 패킷 | RTP 손실률 | +| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| 1 | 29.23 | 28.20 | 23.00 | 0 | 9,958 | 9,694 | 2.65% | 2 | 0.010% | +| 2 | 29.06 | 26.00 | 23.60 | 0 | 9,800 | 9,498 | 3.08% | 0 | 0.000% | +| 3 | 29.25 | 27.40 | 23.00 | 0 | 9,642 | 9,401 | 2.50% | 0 | 0.000% | +| 4 | 29.45 | 28.60 | 26.80 | 0 | 9,484 | 9,307 | 1.87% | 2 | 0.010% | +| 5 | 28.84 | 27.00 | 26.40 | 1 | 9,313 | 9,095 | 2.34% | 0 | 0.000% | +| 6 | 29.08 | 28.80 | 27.00 | 1 | 9,153 | 9,014 | 1.52% | 0 | 0.000% | +| 7 | 28.77 | 26.60 | 25.60 | 1 | 8,999 | 8,769 | 2.56% | 5 | 0.027% | + +## 클라이언트 네트워크 및 연결 상태 + +| 클라이언트 | jitter 평균/p95/최대 | RTT 평균/p95/최대 | 최종 PeerConnection / ICE | +| ---: | ---: | ---: | --- | +| 1 | 3.66 / 6.37 / 8.21ms | 7.81 / 16.89 / 70.02ms | `connected` / `connected` | +| 2 | 3.46 / 6.26 / 7.00ms | 7.71 / 16.51 / 109.45ms | `connected` / `connected` | +| 3 | 3.52 / 7.14 / 8.38ms | 8.97 / 26.31 / 57.74ms | `connected` / `connected` | +| 4 | 3.71 / 6.92 / 10.03ms | 10.46 / 22.00 / 73.36ms | `connected` / `connected` | +| 5 | 3.65 / 6.23 / 8.04ms | 6.43 / 10.96 / 25.89ms | `connected` / `connected` | +| 6 | 3.26 / 5.96 / 6.49ms | 7.42 / 21.93 / 36.70ms | `connected` / `connected` | +| 7 | 3.80 / 6.40 / 8.51ms | 10.37 / 26.95 / 59.91ms | `connected` / `connected` | + +## 서버 수집 결과 + +이 실행에는 동일 시점의 서버 자원 수집기를 연결하지 않았습니다. 바로 다음 8세션 +실행에서 전체 서버 지표를 수집했습니다. + +## 판정 + +모든 세션이 평균 수신 27FPS를 넘었습니다. 최대 프레임 차이는 3.08%, RTT p95 +최댓값은 26.95ms였으며 최종 PeerConnection과 ICE 상태는 모두 `connected`였습니다. +후순위 클라이언트의 0FPS 값은 순차 연결 직후의 초기 속도 표본이며 지속 정지가 +아닙니다. diff --git a/test-docs/results/fixed-delay-20ms/8-sessions.md b/test-docs/results/fixed-delay-20ms/8-sessions.md new file mode 100644 index 0000000..7b5eaee --- /dev/null +++ b/test-docs/results/fixed-delay-20ms/8-sessions.md @@ -0,0 +1,66 @@ +# Python fixed_delay 20ms — 8세션 + +## 시험 정보 + +- 클라이언트 원본: `fixed-delay-20ms-8-final-with-server-metrics.json` / `.csv` +- 서버 원본: `loadtest/server-results/fixed-delay-20ms-8-final/` (Git 제외) +- 시작: 2026-07-17 02:29:13 KST +- 종료: 2026-07-17 02:34:56 KST +- 전체 실행 시간: 343.27초 +- 입력: `720p30.ivf` (1280×720, VP8, 30FPS) +- 안정 구간: 최초 30초 이후 표본 +- 별도 health/ping 트래픽: 사용하지 않음 +- 결과: **통과** + +## 클라이언트 처리량 + +| 클라이언트 | 평균 수신 FPS | 수신 FPS p5 | 0 제외 최소 FPS | 0 표본 | 송신 프레임 | 수신 프레임 | 프레임 차이 | 손실 RTP 패킷 | RTP 손실률 | +| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| 1 | 28.74 | 27.60 | 25.40 | 0 | 10,138 | 9,718 | 4.14% | 0 | 0.000% | +| 2 | 28.44 | 25.80 | 23.46 | 0 | 9,979 | 9,482 | 4.98% | 1 | 0.005% | +| 3 | 28.70 | 27.20 | 24.40 | 0 | 9,809 | 9,391 | 4.26% | 1 | 0.005% | +| 4 | 28.63 | 27.40 | 24.60 | 0 | 9,650 | 9,210 | 4.56% | 0 | 0.000% | +| 5 | 28.18 | 26.40 | 25.20 | 1 | 9,477 | 9,040 | 4.61% | 0 | 0.000% | +| 6 | 28.45 | 27.60 | 25.40 | 1 | 9,318 | 8,974 | 3.69% | 0 | 0.000% | +| 7 | 28.39 | 26.80 | 25.20 | 1 | 9,159 | 8,806 | 3.85% | 0 | 0.000% | +| 8 | 28.38 | 27.80 | 27.60 | 1 | 8,999 | 8,649 | 3.89% | 0 | 0.000% | + +## 클라이언트 네트워크 및 연결 상태 + +| 클라이언트 | jitter 평균/p95/최대 | RTT 평균/p95/최대 | 최종 PeerConnection / ICE | +| ---: | ---: | ---: | --- | +| 1 | 3.60 / 6.52 / 8.94ms | 7.61 / 18.33 / 37.96ms | `connected` / `connected` | +| 2 | 3.90 / 7.01 / 10.20ms | 11.49 / 31.10 / 45.84ms | `connected` / `connected` | +| 3 | 3.52 / 6.62 / 9.08ms | 14.33 / 33.77 / 82.18ms | `connected` / `connected` | +| 4 | 3.68 / 6.23 / 8.69ms | 17.39 / 41.00 / 85.28ms | `connected` / `connected` | +| 5 | 3.55 / 6.87 / 8.13ms | 14.53 / 39.43 / 58.43ms | `connected` / `connected` | +| 6 | 3.37 / 5.51 / 7.33ms | 14.44 / 41.47 / 68.37ms | `connected` / `connected` | +| 7 | 3.44 / 5.99 / 9.62ms | 11.47 / 30.56 / 73.53ms | `connected` / `connected` | +| 8 | 3.76 / 6.39 / 10.42ms | 7.72 / 18.42 / 24.15ms | `connected` / `connected` | + +## 8세션 전체 활성 구간 서버 지표 + +| 지표 | 결과 | 판정 | +| --- | ---: | --- | +| 8세션 전체 활성 구간 | 303.4초 | 참고값 | +| 수신 프레임 | 69,549 | 참고값 | +| 처리 프레임 | 69,545 | 통과 | +| 전체 처리 FPS | 229.22 | 통과 | +| 세션당 처리 FPS | 28.65 | 통과 | +| stale drop | 3,102 / 69,549 (4.46%) | 통과 | +| 처리 오류 | 0 | 통과 | +| 평균 CPU | 4.13코어 | 참고값 | +| 평균 / 최대 RSS | 1.654 / 1.655GiB | 참고값 | +| 처리 큐 최댓값 | 0 | 통과 | +| 종료 후 활성 세션 / 큐 | 0 / 0 | 통과 | +| 종료 후 health | `ready` | 통과 | + +클라이언트 프레임 차이와 서버 stale drop은 일부 관측 구간이 겹치므로 각각 판정하며 +서로 더하지 않습니다. + +## 판정 + +클라이언트와 서버의 모든 기준을 통과했습니다. 5분 fixed-delay 예비 용량 시험에서 +**최소 8개 동시 세션**을 처리할 수 있음을 확인했습니다. 8은 계단식 체크포인트이지 +측정된 최대치가 아닙니다. 최대 안정 용량을 확정하려면 더 높은 부하에서 경계를 찾고 +30분 지속 시험을 진행해야 합니다. diff --git a/test-docs/test-result.md b/test-docs/test-result.md index 545891a..3062e0f 100644 --- a/test-docs/test-result.md +++ b/test-docs/test-result.md @@ -77,12 +77,15 @@ | 동시 세션 | 평균 수신 FPS 범위 | 최대 프레임 차이 | RTT p95 최대 | 연결 유지 | 판정 | | ---: | ---: | ---: | ---: | ---: | --- | -| 1 | 29.61 | 1.31% | 32.71ms | 1/1 | 통과 | -| 2 | 29.56~29.57 | 1.53% | 34.93ms | 2/2 | 통과 | -| 4 | 28.91~29.18 | 3.66% | 96.76ms | 4/4 | 통과 | -| 6 | 28.98~29.68 | 1.82% | 58.03ms | 6/6 | 통과 | -| 7 | 28.77~29.45 | 3.08% | 26.95ms | 7/7 | 통과 | -| 8 | 28.18~28.74 | 4.98% | 41.47ms | 8/8 | 통과 | +| [1](results/fixed-delay-20ms/1-session.md) | 29.61 | 1.31% | 32.71ms | 1/1 | 통과 | +| [2](results/fixed-delay-20ms/2-sessions.md) | 29.56~29.57 | 1.53% | 34.93ms | 2/2 | 통과 | +| [4](results/fixed-delay-20ms/4-sessions.md) | 28.91~29.18 | 3.66% | 96.76ms | 4/4 | 통과 | +| [6](results/fixed-delay-20ms/6-sessions.md) | 28.98~29.68 | 1.82% | 58.03ms | 6/6 | 통과 | +| [7](results/fixed-delay-20ms/7-sessions.md) | 28.77~29.45 | 3.08% | 26.95ms | 7/7 | 통과 | +| [8](results/fixed-delay-20ms/8-sessions.md) | 28.18~28.74 | 4.98% | 41.47ms | 8/8 | 통과 | + +동시 세션 수 링크에는 실행 메타데이터, 클라이언트별 FPS·누적 프레임·RTP 손실, +jitter·RTT 분포, 최종 연결 상태 및 수집 가능한 서버 지표를 분리해 기록했습니다. 최종 8세션 실행에서는 Windows 호스트 수집기로 서버 메트릭도 함께 기록했습니다. 8세션이 모두 활성인 303.4초 동안 69,545프레임을 처리해 전체 229.22FPS, 세션당 From 83c6015b13508843df63fb535de5a1d4a23c5b83 Mon Sep 17 00:00:00 2001 From: Finefinee Date: Fri, 17 Jul 2026 03:07:37 +0900 Subject: [PATCH 10/11] docs: aggregate repeated fixed-delay runs --- .../results/fixed-delay-20ms/1-session.md | 14 ++++- .../results/fixed-delay-20ms/2-sessions.md | 13 ++++- .../results/fixed-delay-20ms/4-sessions.md | 15 ++++- .../results/fixed-delay-20ms/5-sessions.md | 48 ++++++++++++++++ .../results/fixed-delay-20ms/6-sessions.md | 27 ++++++++- .../results/fixed-delay-20ms/7-sessions.md | 13 ++++- .../results/fixed-delay-20ms/8-sessions.md | 16 +++++- test-docs/test-result.md | 55 ++++++++++++------- 8 files changed, 174 insertions(+), 27 deletions(-) create mode 100644 test-docs/results/fixed-delay-20ms/5-sessions.md diff --git a/test-docs/results/fixed-delay-20ms/1-session.md b/test-docs/results/fixed-delay-20ms/1-session.md index 8b788ac..1928cc4 100644 --- a/test-docs/results/fixed-delay-20ms/1-session.md +++ b/test-docs/results/fixed-delay-20ms/1-session.md @@ -1,6 +1,6 @@ # Python fixed_delay 20ms — 1세션 -## 시험 정보 +## 대표 상세 실행 정보 - 클라이언트 원본: `baseline-fixed-delay-20ms-5m.json` / `.csv` - 시작: 2026-07-16 22:29:54 KST @@ -10,6 +10,18 @@ - 안정 구간: 최초 30초 이후 표본 - 결과: **통과** +## 전체 반복 실행 요약 + +| 실행 | 시작 | 평균 수신 FPS | 평균 프레임 차이 | RTT p95 최대 | 최종 연결 | 판정 | +| --- | --- | ---: | ---: | ---: | ---: | --- | +| `baseline-fixed-delay-20ms-5m` | 07-16 22:29:54 | 29.61 | 1.31% | 32.71ms | 1/1 | 통과 | +| `fixed-delay-20ms-stateful-1-after-disconnect-5m` | 07-17 00:08:03 | 29.75 | 0.77% | 18.95ms | 1/1 | 통과 | + +- 전체 성공률: 2/2 (100%) +- 성공 실행 평균 수신 FPS: 29.68 +- 성공 실행 평균 프레임 차이: 1.04% +- 실행별 RTT p95 최댓값 평균: 25.83ms + ## 클라이언트 처리량 | 클라이언트 | 평균 수신 FPS | 수신 FPS p5 | 0 제외 최소 FPS | 0 표본 | 송신 프레임 | 수신 프레임 | 프레임 차이 | 손실 RTP 패킷 | RTP 손실률 | diff --git a/test-docs/results/fixed-delay-20ms/2-sessions.md b/test-docs/results/fixed-delay-20ms/2-sessions.md index eb39bfb..a465bff 100644 --- a/test-docs/results/fixed-delay-20ms/2-sessions.md +++ b/test-docs/results/fixed-delay-20ms/2-sessions.md @@ -1,6 +1,6 @@ # Python fixed_delay 20ms — 2세션 -## 시험 정보 +## 대표 상세 실행 정보 - 클라이언트 원본: `fixed-delay-20ms-2-clients-5m.json` / `.csv` - 시작: 2026-07-16 22:39:35 KST @@ -10,6 +10,17 @@ - 안정 구간: 최초 30초 이후 표본 - 결과: **통과** +## 전체 반복 실행 요약 + +| 실행 | 시작 | 평균 수신 FPS | 최저 세션 평균 FPS | 평균/최대 프레임 차이 | RTT p95 최대 | 최종 연결 | 판정 | +| --- | --- | ---: | ---: | ---: | ---: | ---: | --- | +| `fixed-delay-20ms-2-clients-5m` | 07-16 22:39:35 | 29.57 | 29.56 | 1.48% / 1.53% | 34.93ms | 2/2 | 통과 | + +- 전체 성공률: 1/1 (100%) +- 성공 실행 평균 수신 FPS: 29.57 +- 성공 실행 평균 프레임 차이: 1.48% +- 반복 실행이 1건이므로 반복성은 아직 확정하지 않습니다. + ## 클라이언트 처리량 | 클라이언트 | 평균 수신 FPS | 수신 FPS p5 | 0 제외 최소 FPS | 0 표본 | 송신 프레임 | 수신 프레임 | 프레임 차이 | 손실 RTP 패킷 | RTP 손실률 | diff --git a/test-docs/results/fixed-delay-20ms/4-sessions.md b/test-docs/results/fixed-delay-20ms/4-sessions.md index 49f361f..702636e 100644 --- a/test-docs/results/fixed-delay-20ms/4-sessions.md +++ b/test-docs/results/fixed-delay-20ms/4-sessions.md @@ -1,6 +1,6 @@ # Python fixed_delay 20ms — 4세션 -## 시험 정보 +## 대표 상세 실행 정보 - 클라이언트 원본: `fixed-delay-20ms-4-clients-5m.json` / `.csv` - 시작: 2026-07-16 22:49:14 KST @@ -10,6 +10,19 @@ - 안정 구간: 최초 30초 이후 표본 - 결과: **통과** +## 전체 반복 실행 요약 + +| 실행 | 시작 | 평균 수신 FPS | 최저 세션 평균 FPS | 평균/최대 프레임 차이 | RTT p95 최대 | 최종 연결 | 판정 | +| --- | --- | ---: | ---: | ---: | ---: | ---: | --- | +| `fixed-delay-20ms-4-clients-5m` | 07-16 22:49:14 | 29.06 | 28.91 | 3.18% / 3.66% | 96.76ms | 4/4 | 통과 | +| `fixed-delay-20ms-stateful-4-after-disconnect-5m` | 07-17 00:16:09 | 29.62 | 29.48 | 1.25% / 1.69% | 58.98ms | 4/4 | 통과 | + +- 전체 성공률: 2/2 (100%) +- 성공 실행 평균 수신 FPS: 29.34 +- 성공 실행 평균 프레임 차이: 2.22% +- 실행별 최대 프레임 차이 평균: 2.67% +- 실행별 RTT p95 최댓값 평균: 77.87ms + ## 클라이언트 처리량 | 클라이언트 | 평균 수신 FPS | 수신 FPS p5 | 0 제외 최소 FPS | 0 표본 | 송신 프레임 | 수신 프레임 | 프레임 차이 | 손실 RTP 패킷 | RTP 손실률 | diff --git a/test-docs/results/fixed-delay-20ms/5-sessions.md b/test-docs/results/fixed-delay-20ms/5-sessions.md new file mode 100644 index 0000000..a1bad16 --- /dev/null +++ b/test-docs/results/fixed-delay-20ms/5-sessions.md @@ -0,0 +1,48 @@ +# Python fixed_delay 20ms — 5세션 + +## 시험 정보 + +- 클라이언트 원본: `fixed-delay-20ms-stateful-5-boundary-5m.json` / `.csv` +- 시작: 2026-07-17 00:30:55 KST +- 종료: 2026-07-17 00:36:21 KST +- 전체 실행 시간: 326.49초 +- 입력: `720p30.ivf` (1280×720, VP8, 30FPS) +- 안정 구간: 최초 30초 이후 표본 +- 결과: **실패** + +## 전체 반복 실행 요약 + +| 실행 | 평균 수신 FPS | 최저 세션 평균 FPS | 평균/최대 프레임 차이 | RTT p95 최대 | 최종 연결 | 판정 | +| --- | ---: | ---: | ---: | ---: | ---: | --- | +| `fixed-delay-20ms-stateful-5-boundary-5m` | 23.49 | 20.46 | 20.84% / 30.03% | 1,404.51ms | 4/5 | 실패 | + +- 전체 성공률: 0/1 (0%) +- 성공 실행 평균: 성공 실행이 없어 계산하지 않음 +- 단일 실패 실행만 있으므로 서버의 5세션 고정 용량 경계로 확정하지 않음 + +## 클라이언트 처리량 + +| 클라이언트 | 평균 수신 FPS | 수신 FPS p5 | 0 제외 최소 FPS | 0 표본 | 송신 프레임 | 수신 프레임 | 프레임 차이 | 손실 RTP 패킷 | RTP 손실률 | +| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| 1 | 24.62 | 7.40 | 5.60 | 0 | 9,635 | 7,920 | 17.80% | 2 | 0.011% | +| 2 | 20.46 | 0.00 | 4.00 | 14 | 9,476 | 6,630 | 30.03% | 0 | 0.000% | +| 3 | 23.93 | 6.60 | 4.60 | 0 | 9,318 | 7,461 | 19.93% | 5 | 0.027% | +| 4 | 24.39 | 7.60 | 5.80 | 0 | 9,159 | 7,479 | 18.34% | 5 | 0.028% | +| 5 | 24.06 | 6.20 | 5.00 | 1 | 8,999 | 7,371 | 18.09% | 3 | 0.017% | + +## 클라이언트 네트워크 및 연결 상태 + +| 클라이언트 | jitter 평균/p95/최대 | RTT 평균/p95/최대 | 최종 PeerConnection / ICE | +| ---: | ---: | ---: | --- | +| 1 | 5.96 / 16.13 / 21.44ms | 246.80 / 1,350.62 / 1,478.47ms | `connected` / `connected` | +| 2 | 6.31 / 11.78 / 17.42ms | 421.75 / 1,304.28 / 1,473.51ms | `failed` / `failed` | +| 3 | 5.47 / 15.27 / 23.32ms | 266.83 / 1,394.26 / 1,698.52ms | `connected` / `connected` | +| 4 | 6.05 / 16.26 / 28.17ms | 249.54 / 1,404.51 / 1,587.83ms | `connected` / `connected` | +| 5 | 5.74 / 16.25 / 19.25ms | 240.66 / 1,389.36 / 1,421.40ms | `connected` / `connected` | + +## 판정 + +모든 세션의 평균 FPS와 프레임 차이가 기준을 위반했고 RTT도 초 단위로 증가했습니다. +이후 같은 서버에서 6·7·8세션이 통과했으므로 이 결과는 서버의 단조로운 용량 한계가 +아니라 당시 공인 UDP/NAT 경로 이상 사례로 분류합니다. 실패 결과 자체는 전체 반복 +성공률과 네트워크 이상 이력에 포함합니다. diff --git a/test-docs/results/fixed-delay-20ms/6-sessions.md b/test-docs/results/fixed-delay-20ms/6-sessions.md index 68279da..881c4a2 100644 --- a/test-docs/results/fixed-delay-20ms/6-sessions.md +++ b/test-docs/results/fixed-delay-20ms/6-sessions.md @@ -1,6 +1,6 @@ # Python fixed_delay 20ms — 6세션 -## 시험 정보 +## 대표 상세 실행 정보 - 클라이언트 원본: `fixed-delay-20ms-6-no-diagnostic-traffic.json` / `.csv` - 시작: 2026-07-17 02:02:52 KST @@ -11,6 +11,31 @@ - 별도 health/ping 트래픽: 사용하지 않음 - 결과: **통과** +## 전체 반복 실행 요약 + +| 실행 | 시작 | 평균 수신 FPS | 최저 세션 평균 FPS | 평균/최대 프레임 차이 | RTT p95 최대 | 최종 연결 | 판정 | +| --- | --- | ---: | ---: | ---: | ---: | ---: | --- | +| `fixed-delay-20ms-6-clients-5m` | 07-16 23:43:19 | 13.72 | 6.63 | 52.21% / 76.99% | 889.88ms | 2/6 | 실패 | +| `fixed-delay-20ms-6-clients-stateful-no-profiler` | 07-16 23:58:17 | 15.29 | 14.86 | 46.98% / 48.68% | 1,041.49ms | 0/6 | 실패 | +| `fixed-delay-20ms-stateful-6-repeat-1` | 07-17 00:44:36 | 15.93 | 8.55 | 45.72% / 70.01% | 2,169.56ms | 2/6 | 실패 | +| `fixed-delay-20ms-stateful-6-repeat-2` | 07-17 00:57:37 | 14.56 | 0.17 | 50.32% / 96.42% | 3,420.81ms | 2/6 | 실패 | +| `fixed-delay-20ms-fresh-server-6-compare` | 07-17 01:08:08 | 1.37 | 0.74 | 93.98% / 97.34% | 4,151.75ms | 0/6 | 실패 | +| `fixed-delay-20ms-6-network-diagnostic` | 07-17 01:22:39 | 29.29 | 28.83 | 1.89% / 2.42% | 61.48ms | 6/6 | 통과 | +| `fixed-delay-20ms-6-back-to-back-diagnostic` | 07-17 01:34:26 | 29.04 | 28.65 | 2.66% / 2.90% | 52.41ms | 6/6 | 통과 | +| `fixed-delay-20ms-6-back-to-back-diagnostic2` | 07-17 01:45:09 | 29.31 | 28.94 | 1.72% / 1.91% | 228.74ms | 6/6 | 통과 | +| `fixed-delay-20ms-6-back-to-back-diagnostic3` | 07-17 01:50:46 | 29.15 | 28.82 | 2.33% / 2.51% | 91.90ms | 6/6 | 통과 | +| `fixed-delay-20ms-6-no-diagnostic-traffic` | 07-17 02:02:52 | 29.39 | 28.98 | 1.53% / 1.82% | 58.03ms | 6/6 | 통과 | + +- 전체 성공률: 5/10 (50%) +- 성공 실행 평균 수신 FPS: 29.24 +- 성공 실행 평균 프레임 차이: 2.03% +- 성공 실행별 최대 프레임 차이 평균: 2.31% +- 성공 실행별 RTT p95 최댓값 평균: 98.51ms + +정상 수치만 보면 6세션 처리량은 충분하지만 공인망 End-to-End 반복성은 50%입니다. +실패 실행도 평균에서 삭제하지 않고 위 표와 성공률에 보존합니다. 다만 정상 성능 평균은 +서버가 처리 가능한 상태의 수치를 보기 위해 통과한 5건만 사용합니다. + ## 클라이언트 처리량 | 클라이언트 | 평균 수신 FPS | 수신 FPS p5 | 0 제외 최소 FPS | 0 표본 | 송신 프레임 | 수신 프레임 | 프레임 차이 | 손실 RTP 패킷 | RTP 손실률 | diff --git a/test-docs/results/fixed-delay-20ms/7-sessions.md b/test-docs/results/fixed-delay-20ms/7-sessions.md index 1e3e8ed..46f6acf 100644 --- a/test-docs/results/fixed-delay-20ms/7-sessions.md +++ b/test-docs/results/fixed-delay-20ms/7-sessions.md @@ -1,6 +1,6 @@ # Python fixed_delay 20ms — 7세션 -## 시험 정보 +## 대표 상세 실행 정보 - 클라이언트 원본: `fixed-delay-20ms-7-no-diagnostic.json` / `.csv` - 시작: 2026-07-17 02:12:36 KST @@ -11,6 +11,17 @@ - 별도 health/ping 트래픽: 사용하지 않음 - 결과: **통과** +## 전체 반복 실행 요약 + +| 실행 | 시작 | 평균 수신 FPS | 최저 세션 평균 FPS | 평균/최대 프레임 차이 | RTT p95 최대 | 최종 연결 | 판정 | +| --- | --- | ---: | ---: | ---: | ---: | ---: | --- | +| `fixed-delay-20ms-7-no-diagnostic` | 07-17 02:12:36 | 29.10 | 28.77 | 2.36% / 3.08% | 26.95ms | 7/7 | 통과 | + +- 전체 성공률: 1/1 (100%) +- 성공 실행 평균 수신 FPS: 29.10 +- 성공 실행 평균 프레임 차이: 2.36% +- 반복 실행이 1건이므로 반복성은 아직 확정하지 않습니다. + ## 클라이언트 처리량 | 클라이언트 | 평균 수신 FPS | 수신 FPS p5 | 0 제외 최소 FPS | 0 표본 | 송신 프레임 | 수신 프레임 | 프레임 차이 | 손실 RTP 패킷 | RTP 손실률 | diff --git a/test-docs/results/fixed-delay-20ms/8-sessions.md b/test-docs/results/fixed-delay-20ms/8-sessions.md index 7b5eaee..dbcaeb6 100644 --- a/test-docs/results/fixed-delay-20ms/8-sessions.md +++ b/test-docs/results/fixed-delay-20ms/8-sessions.md @@ -1,6 +1,6 @@ # Python fixed_delay 20ms — 8세션 -## 시험 정보 +## 대표 상세 실행 정보 - 클라이언트 원본: `fixed-delay-20ms-8-final-with-server-metrics.json` / `.csv` - 서버 원본: `loadtest/server-results/fixed-delay-20ms-8-final/` (Git 제외) @@ -12,6 +12,20 @@ - 별도 health/ping 트래픽: 사용하지 않음 - 결과: **통과** +## 전체 반복 실행 요약 + +| 실행 | 시작 | 평균 수신 FPS | 최저 세션 평균 FPS | 평균/최대 프레임 차이 | RTT p95 최대 | 최종 연결 | 판정 | +| --- | --- | ---: | ---: | ---: | ---: | ---: | --- | +| `fixed-delay-20ms-8-clients-5m` | 07-16 23:00:19 | 28.05 | 27.68 | 5.68% / 6.33% | 218.81ms | 8/8 | 통과 | +| `fixed-delay-20ms-8-no-diagnostic` | 07-17 02:18:16 | 29.09 | 28.69 | 2.29% / 3.01% | 35.02ms | 8/8 | 통과 | +| `fixed-delay-20ms-8-final-with-server-metrics` | 07-17 02:29:13 | 28.49 | 28.18 | 4.25% / 4.98% | 41.47ms | 8/8 | 통과 | + +- 전체 성공률: 3/3 (100%) +- 성공 실행 평균 수신 FPS: 28.54 +- 성공 실행 평균 프레임 차이: 4.07% +- 성공 실행별 최대 프레임 차이 평균: 4.77% +- 성공 실행별 RTT p95 최댓값 평균: 98.43ms + ## 클라이언트 처리량 | 클라이언트 | 평균 수신 FPS | 수신 FPS p5 | 0 제외 최소 FPS | 0 표본 | 송신 프레임 | 수신 프레임 | 프레임 차이 | 손실 RTP 패킷 | RTP 손실률 | diff --git a/test-docs/test-result.md b/test-docs/test-result.md index 3062e0f..2229a03 100644 --- a/test-docs/test-result.md +++ b/test-docs/test-result.md @@ -71,21 +71,31 @@ ## Python fixed_delay 20ms 다중 세션 예비 용량 2026-07-16~17에 같은 Python/aiortc 서버 프로세스와 `720p30.ivf`를 사용해 -`fixed_delay 20ms`를 1·2·4·6·7·8세션으로 측정했습니다. 각 실행은 5분이며 첫 -30초 이후의 클라이언트 표본을 집계했습니다. 아래 값은 각 세션의 평균 수신 FPS 범위와 -클라이언트 중 가장 큰 송수신 프레임 차이 및 RTT p95입니다. - -| 동시 세션 | 평균 수신 FPS 범위 | 최대 프레임 차이 | RTT p95 최대 | 연결 유지 | 판정 | -| ---: | ---: | ---: | ---: | ---: | --- | -| [1](results/fixed-delay-20ms/1-session.md) | 29.61 | 1.31% | 32.71ms | 1/1 | 통과 | -| [2](results/fixed-delay-20ms/2-sessions.md) | 29.56~29.57 | 1.53% | 34.93ms | 2/2 | 통과 | -| [4](results/fixed-delay-20ms/4-sessions.md) | 28.91~29.18 | 3.66% | 96.76ms | 4/4 | 통과 | -| [6](results/fixed-delay-20ms/6-sessions.md) | 28.98~29.68 | 1.82% | 58.03ms | 6/6 | 통과 | -| [7](results/fixed-delay-20ms/7-sessions.md) | 28.77~29.45 | 3.08% | 26.95ms | 7/7 | 통과 | -| [8](results/fixed-delay-20ms/8-sessions.md) | 28.18~28.74 | 4.98% | 41.47ms | 8/8 | 통과 | - -동시 세션 수 링크에는 실행 메타데이터, 클라이언트별 FPS·누적 프레임·RTP 손실, -jitter·RTT 분포, 최종 연결 상태 및 수집 가능한 서버 지표를 분리해 기록했습니다. +`fixed_delay 20ms`를 1·2·4·5·6·7·8세션으로 측정했습니다. 각 실행은 5분이며 첫 +30초 이후의 클라이언트 표본을 집계했습니다. 반복 실행을 대표 성공 1건으로 축약하지 +않고 모두 기록합니다. 아래 정상 평균은 모든 세션이 FPS·프레임 차이·연결 기준을 +만족한 실행만 집계한 값이며, 선택 편향을 피하기 위해 전체 성공률을 함께 표시합니다. + +| 동시 세션 | 성공/전체 실행 | 성공 실행 평균 수신 FPS | 성공 실행 평균 프레임 차이 | 실행별 최대 차이 평균 | 실행별 RTT p95 최댓값 평균 | 반복 판정 | +| ---: | ---: | ---: | ---: | ---: | ---: | --- | +| [1](results/fixed-delay-20ms/1-session.md) | 2/2 | 29.68 | 1.04% | 1.04% | 25.83ms | 통과 | +| [2](results/fixed-delay-20ms/2-sessions.md) | 1/1 | 29.57 | 1.48% | 1.53% | 34.93ms | 통과, 반복 필요 | +| [4](results/fixed-delay-20ms/4-sessions.md) | 2/2 | 29.34 | 2.22% | 2.67% | 77.87ms | 통과 | +| [5](results/fixed-delay-20ms/5-sessions.md) | 0/1 | - | - | - | - | 네트워크 이상 실패 1건 | +| [6](results/fixed-delay-20ms/6-sessions.md) | 5/10 | 29.24 | 2.03% | 2.31% | 98.51ms | 정상 처리 가능, 공인망 반복성 50% | +| [7](results/fixed-delay-20ms/7-sessions.md) | 1/1 | 29.10 | 2.36% | 3.08% | 26.95ms | 통과, 반복 필요 | +| [8](results/fixed-delay-20ms/8-sessions.md) | 3/3 | 28.54 | 4.07% | 4.77% | 98.43ms | 통과 | + +동시 세션 수 링크에는 해당 세션 수로 수행한 모든 실행의 이름과 판정, 정상 실행 +평균, 대표 실행의 클라이언트별 FPS·누적 프레임·RTP 손실, jitter·RTT 분포, 최종 +연결 상태 및 수집 가능한 서버 지표를 기록했습니다. 성공 실행 평균은 실패를 숨기는 +전체 평균이 아니며 반드시 `성공/전체 실행`과 함께 해석합니다. + +집계 순서는 다음과 같습니다. 먼저 최초 30초 이후 각 클라이언트의 평균을 계산하고, +이를 산술 평균해 실행 평균을 만듭니다. 정상 평균은 통과 실행들의 실행 평균을 다시 +동일 가중치로 산술 평균합니다. 한 클라이언트라도 평균 수신 27FPS 미만, 프레임 차이 +10% 초과, 최종 연결 실패 또는 처리 오류가 있으면 해당 실행 전체를 실패로 분류합니다. +실패 실행은 정상 평균에서는 제외하지만 전체 실행 수와 성공률에서는 제외하지 않습니다. 최종 8세션 실행에서는 Windows 호스트 수집기로 서버 메트릭도 함께 기록했습니다. 8세션이 모두 활성인 303.4초 동안 69,545프레임을 처리해 전체 229.22FPS, 세션당 @@ -93,9 +103,11 @@ jitter·RTT 분포, 최종 연결 상태 및 수집 가능한 서버 지표를 0건이었습니다. 평균 CPU는 약 4.13코어, 최대 RSS는 약 1.66GiB였고 처리 큐는 시험 내내 0이었습니다. 종료 후 활성 세션과 큐도 모두 0이었습니다. -따라서 5분 예비 용량 시험 기준으로 **Python fixed_delay의 확인 용량은 최소 -8세션**입니다. 8은 계단식 시험 체크포인트이며 최대치가 아닙니다. 최종 최대 안정 -세션을 주장하려면 더 높은 부하에서 경계를 찾고 후보 세션 수를 30분 이상 유지해야 +따라서 네트워크가 정상인 구간의 5분 예비 용량 시험 기준으로 **Python fixed_delay의 +확인 처리 용량은 최소 8세션**입니다. 다만 공인망 전체 반복성은 특히 6세션에서 +5/10이므로 이를 곧바로 End-to-End 최대 안정 세션으로 부르지 않습니다. 8은 계단식 +시험 체크포인트이며 최대치가 아닙니다. 최종 최대 안정 세션을 주장하려면 통제된 LAN +또는 TURN 경로에서 더 높은 부하의 경계를 찾고 후보 세션 수를 30분 이상 유지해야 합니다. ### 비단조 실패 실행과 네트워크 판정 @@ -108,9 +120,10 @@ jitter·RTT 분포, 최종 연결 상태 및 수집 가능한 서버 지표를 이 비단조 실패는 서버의 고정 용량 경계, 누적 상태, profiler 부하 또는 cooldown으로 설명되지 않습니다. 현재 가장 가능성 높은 원인은 공인 인터넷 구간의 일시적인 WebRTC -UDP/NAT 경로 이상입니다. 실패 원본은 네트워크 장애 사례로 보존하되 언어별 서버 용량 -판정값에는 포함하지 않습니다. 최종 용량 비교는 가능하면 같은 LAN 또는 통제된 TURN -경로에서 반복합니다. +UDP/NAT 경로 이상입니다. 실패 원본도 세션별 상세 문서와 전체 성공률에 포함합니다. +다만 정상 성능 수치의 평균에는 성공 실행만 포함하고, 언어별 서버 처리량 비교에서는 +통제되지 않은 네트워크 실패를 별도 표시합니다. 최종 용량 비교는 가능하면 같은 LAN +또는 통제된 TURN 경로에서 반복합니다. ## Python 단일 세션 5분 기준선 From e4c3bdcadc502cc12472aca20a27b88870f7dae2 Mon Sep 17 00:00:00 2001 From: Finefinee Date: Fri, 17 Jul 2026 03:41:51 +0900 Subject: [PATCH 11/11] docs: clarify fixed-delay load evidence --- loadtest/windows/collect-server-metrics.ps1 | 54 ++++++++++++- .../results/fixed-delay-20ms/1-session.md | 5 +- .../results/fixed-delay-20ms/2-sessions.md | 5 +- .../results/fixed-delay-20ms/4-sessions.md | 5 +- .../results/fixed-delay-20ms/5-sessions.md | 3 +- .../results/fixed-delay-20ms/6-sessions.md | 10 ++- .../results/fixed-delay-20ms/7-sessions.md | 8 +- .../results/fixed-delay-20ms/8-sessions.md | 26 +++--- .../fixed-delay-20ms/artifact-manifest.md | 79 +++++++++++++++++++ test-docs/test-backlog.md | 5 +- test-docs/test-plan.md | 4 +- test-docs/test-result.md | 78 +++++++++++++----- test-docs/test-runbook.md | 8 ++ 13 files changed, 238 insertions(+), 52 deletions(-) create mode 100644 test-docs/results/fixed-delay-20ms/artifact-manifest.md diff --git a/loadtest/windows/collect-server-metrics.ps1 b/loadtest/windows/collect-server-metrics.ps1 index ea75d83..d0655c9 100644 --- a/loadtest/windows/collect-server-metrics.ps1 +++ b/loadtest/windows/collect-server-metrics.ps1 @@ -20,7 +20,8 @@ function Get-MetricValue { param( [string]$Text, [string]$Name, - [string]$Labels = "" + [string]$Labels = "", + [switch]$ZeroIfMissing ) $escapedName = [regex]::Escape($Name) @@ -35,7 +36,24 @@ function Get-MetricValue { if ($match.Success) { return [double]$match.Groups[1].Value } - return 0.0 + if ($ZeroIfMissing) { + return 0.0 + } + + $metric = if ($Labels) { "${Name}{${Labels}}" } else { $Name } + throw "Required Prometheus metric not found: $metric" +} + +function Assert-MetricFamily { + param( + [string]$Text, + [string]$Name + ) + + $escapedName = [regex]::Escape($Name) + if (-not [regex]::IsMatch($Text, "(?m)^# TYPE ${escapedName} ")) { + throw "Required Prometheus metric family not found: $Name" + } } New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null @@ -45,6 +63,19 @@ $samplesPath = Join-Path $OutputDirectory "app.csv" $modeLabels = "mode=`"$Mode`"" $initialMetrics = Get-MetricsText +$requiredMetricFamilies = @( + "innolive_active_sessions", + "innolive_processing_queue_size", + "innolive_frame_received_total", + "innolive_frame_processed_total", + "innolive_frame_dropped_total", + "innolive_frame_failures_total", + "process_cpu_seconds_total", + "process_resident_memory_bytes" +) +foreach ($metricName in $requiredMetricFamilies) { + Assert-MetricFamily -Text $initialMetrics -Name $metricName +} $initialMetrics | Set-Content -Encoding utf8 -Path $initialMetricsPath (Invoke-WebRequest -UseBasicParsing -Uri "$BaseUrl/health" -TimeoutSec 5).Content | Set-Content -Encoding utf8 -Path (Join-Path $OutputDirectory "health-initial.json") @@ -67,6 +98,21 @@ do { } while ($true) Write-Host "Active session detected; collecting for ${DurationSeconds}s" +$metricDeadline = (Get-Date).AddSeconds(10) +do { + $metrics = Get-MetricsText + try { + $null = Get-MetricValue -Text $metrics -Name "innolive_frame_received_total" -Labels $modeLabels + $null = Get-MetricValue -Text $metrics -Name "innolive_frame_processed_total" -Labels $modeLabels + break + } catch { + if ((Get-Date) -ge $metricDeadline) { + throw + } + Start-Sleep -Seconds 1 + } +} while ($true) + $end = (Get-Date).AddSeconds($DurationSeconds) do { $metrics = Get-MetricsText @@ -75,8 +121,8 @@ do { $queue = Get-MetricValue -Text $metrics -Name "innolive_processing_queue_size" $received = Get-MetricValue -Text $metrics -Name "innolive_frame_received_total" -Labels $modeLabels $processed = Get-MetricValue -Text $metrics -Name "innolive_frame_processed_total" -Labels $modeLabels - $dropped = Get-MetricValue -Text $metrics -Name "innolive_frame_dropped_total" -Labels $modeLabels - $failures = Get-MetricValue -Text $metrics -Name "innolive_frame_failures_total" -Labels $modeLabels + $dropped = Get-MetricValue -Text $metrics -Name "innolive_frame_dropped_total" -Labels $modeLabels -ZeroIfMissing + $failures = Get-MetricValue -Text $metrics -Name "innolive_frame_failures_total" -Labels $modeLabels -ZeroIfMissing $cpu = Get-MetricValue -Text $metrics -Name "process_cpu_seconds_total" $rss = Get-MetricValue -Text $metrics -Name "process_resident_memory_bytes" diff --git a/test-docs/results/fixed-delay-20ms/1-session.md b/test-docs/results/fixed-delay-20ms/1-session.md index 1928cc4..21da3ab 100644 --- a/test-docs/results/fixed-delay-20ms/1-session.md +++ b/test-docs/results/fixed-delay-20ms/1-session.md @@ -3,16 +3,17 @@ ## 대표 상세 실행 정보 - 클라이언트 원본: `baseline-fixed-delay-20ms-5m.json` / `.csv` +- 원본 검증값: [artifact-manifest.md](artifact-manifest.md) - 시작: 2026-07-16 22:29:54 KST - 종료: 2026-07-16 22:34:59 KST - 전체 실행 시간: 305.48초 - 입력: `720p30.ivf` (1280×720, VP8, 30FPS) - 안정 구간: 최초 30초 이후 표본 -- 결과: **통과** +- 결과: **대표 실행 통합 기준 통과** ## 전체 반복 실행 요약 -| 실행 | 시작 | 평균 수신 FPS | 평균 프레임 차이 | RTT p95 최대 | 최종 연결 | 판정 | +| 실행 | 시작 | 평균 수신 FPS | 평균 프레임 차이 | RTT p95 최대 | 최종 연결 | 클라이언트 판정 | | --- | --- | ---: | ---: | ---: | ---: | --- | | `baseline-fixed-delay-20ms-5m` | 07-16 22:29:54 | 29.61 | 1.31% | 32.71ms | 1/1 | 통과 | | `fixed-delay-20ms-stateful-1-after-disconnect-5m` | 07-17 00:08:03 | 29.75 | 0.77% | 18.95ms | 1/1 | 통과 | diff --git a/test-docs/results/fixed-delay-20ms/2-sessions.md b/test-docs/results/fixed-delay-20ms/2-sessions.md index a465bff..d2ec976 100644 --- a/test-docs/results/fixed-delay-20ms/2-sessions.md +++ b/test-docs/results/fixed-delay-20ms/2-sessions.md @@ -3,16 +3,17 @@ ## 대표 상세 실행 정보 - 클라이언트 원본: `fixed-delay-20ms-2-clients-5m.json` / `.csv` +- 원본 검증값: [artifact-manifest.md](artifact-manifest.md) - 시작: 2026-07-16 22:39:35 KST - 종료: 2026-07-16 22:44:46 KST - 전체 실행 시간: 310.63초 - 입력: `720p30.ivf` (1280×720, VP8, 30FPS) - 안정 구간: 최초 30초 이후 표본 -- 결과: **통과** +- 결과: **통합 기준 통과** ## 전체 반복 실행 요약 -| 실행 | 시작 | 평균 수신 FPS | 최저 세션 평균 FPS | 평균/최대 프레임 차이 | RTT p95 최대 | 최종 연결 | 판정 | +| 실행 | 시작 | 평균 수신 FPS | 최저 세션 평균 FPS | 평균/최대 프레임 차이 | RTT p95 최대 | 최종 연결 | 클라이언트 판정 | | --- | --- | ---: | ---: | ---: | ---: | ---: | --- | | `fixed-delay-20ms-2-clients-5m` | 07-16 22:39:35 | 29.57 | 29.56 | 1.48% / 1.53% | 34.93ms | 2/2 | 통과 | diff --git a/test-docs/results/fixed-delay-20ms/4-sessions.md b/test-docs/results/fixed-delay-20ms/4-sessions.md index 702636e..467e03d 100644 --- a/test-docs/results/fixed-delay-20ms/4-sessions.md +++ b/test-docs/results/fixed-delay-20ms/4-sessions.md @@ -3,16 +3,17 @@ ## 대표 상세 실행 정보 - 클라이언트 원본: `fixed-delay-20ms-4-clients-5m.json` / `.csv` +- 원본 검증값: [artifact-manifest.md](artifact-manifest.md) - 시작: 2026-07-16 22:49:14 KST - 종료: 2026-07-16 22:54:35 KST - 전체 실행 시간: 321.27초 - 입력: `720p30.ivf` (1280×720, VP8, 30FPS) - 안정 구간: 최초 30초 이후 표본 -- 결과: **통과** +- 결과: **대표 실행 통합 기준 통과** ## 전체 반복 실행 요약 -| 실행 | 시작 | 평균 수신 FPS | 최저 세션 평균 FPS | 평균/최대 프레임 차이 | RTT p95 최대 | 최종 연결 | 판정 | +| 실행 | 시작 | 평균 수신 FPS | 최저 세션 평균 FPS | 평균/최대 프레임 차이 | RTT p95 최대 | 최종 연결 | 클라이언트 판정 | | --- | --- | ---: | ---: | ---: | ---: | ---: | --- | | `fixed-delay-20ms-4-clients-5m` | 07-16 22:49:14 | 29.06 | 28.91 | 3.18% / 3.66% | 96.76ms | 4/4 | 통과 | | `fixed-delay-20ms-stateful-4-after-disconnect-5m` | 07-17 00:16:09 | 29.62 | 29.48 | 1.25% / 1.69% | 58.98ms | 4/4 | 통과 | diff --git a/test-docs/results/fixed-delay-20ms/5-sessions.md b/test-docs/results/fixed-delay-20ms/5-sessions.md index a1bad16..914991e 100644 --- a/test-docs/results/fixed-delay-20ms/5-sessions.md +++ b/test-docs/results/fixed-delay-20ms/5-sessions.md @@ -3,6 +3,7 @@ ## 시험 정보 - 클라이언트 원본: `fixed-delay-20ms-stateful-5-boundary-5m.json` / `.csv` +- 원본 검증값: [artifact-manifest.md](artifact-manifest.md) - 시작: 2026-07-17 00:30:55 KST - 종료: 2026-07-17 00:36:21 KST - 전체 실행 시간: 326.49초 @@ -12,7 +13,7 @@ ## 전체 반복 실행 요약 -| 실행 | 평균 수신 FPS | 최저 세션 평균 FPS | 평균/최대 프레임 차이 | RTT p95 최대 | 최종 연결 | 판정 | +| 실행 | 평균 수신 FPS | 최저 세션 평균 FPS | 평균/최대 프레임 차이 | RTT p95 최대 | 최종 연결 | 클라이언트 판정 | | --- | ---: | ---: | ---: | ---: | ---: | --- | | `fixed-delay-20ms-stateful-5-boundary-5m` | 23.49 | 20.46 | 20.84% / 30.03% | 1,404.51ms | 4/5 | 실패 | diff --git a/test-docs/results/fixed-delay-20ms/6-sessions.md b/test-docs/results/fixed-delay-20ms/6-sessions.md index 881c4a2..2e6aef0 100644 --- a/test-docs/results/fixed-delay-20ms/6-sessions.md +++ b/test-docs/results/fixed-delay-20ms/6-sessions.md @@ -3,17 +3,18 @@ ## 대표 상세 실행 정보 - 클라이언트 원본: `fixed-delay-20ms-6-no-diagnostic-traffic.json` / `.csv` +- 원본 검증값: [artifact-manifest.md](artifact-manifest.md) - 시작: 2026-07-17 02:02:52 KST - 종료: 2026-07-17 02:08:24 KST - 전체 실행 시간: 332.25초 - 입력: `720p30.ivf` (1280×720, VP8, 30FPS) - 안정 구간: 최초 30초 이후 표본 - 별도 health/ping 트래픽: 사용하지 않음 -- 결과: **통과** +- 결과: **클라이언트 기준 통과, 서버 기준 미측정** ## 전체 반복 실행 요약 -| 실행 | 시작 | 평균 수신 FPS | 최저 세션 평균 FPS | 평균/최대 프레임 차이 | RTT p95 최대 | 최종 연결 | 판정 | +| 실행 | 시작 | 평균 수신 FPS | 최저 세션 평균 FPS | 평균/최대 프레임 차이 | RTT p95 최대 | 최종 연결 | 클라이언트 판정 | | --- | --- | ---: | ---: | ---: | ---: | ---: | --- | | `fixed-delay-20ms-6-clients-5m` | 07-16 23:43:19 | 13.72 | 6.63 | 52.21% / 76.99% | 889.88ms | 2/6 | 실패 | | `fixed-delay-20ms-6-clients-stateful-no-profiler` | 07-16 23:58:17 | 15.29 | 14.86 | 46.98% / 48.68% | 1,041.49ms | 0/6 | 실패 | @@ -66,10 +67,11 @@ ## 판정 및 반복성 -여섯 세션이 모두 통과했습니다. 최대 프레임 차이는 1.82%, RTT p95 최댓값은 +여섯 세션이 클라이언트 기준을 모두 통과했습니다. 최대 프레임 차이는 1.82%, RTT p95 최댓값은 58.03ms였고 최종 연결 상태는 모두 `connected`였습니다. 클라이언트 5와 6의 0FPS 표본 1개는 순차 연결 직후의 첫 속도 계산 표본이며 이후 누적 수신 프레임은 계속 증가했습니다. 이전에 발생한 비단조 6세션 실패는 상위 결과 문서에서 공인 UDP/NAT 경로 이상으로 -분리했으며 서버 용량 경계로 판정하지 않았습니다. +분리했으며 서버 용량 경계로 판정하지 않았습니다. 대표 실행에는 동시점 서버 지표가 +없으므로 서버 FPS·오류율·큐 기준의 전체 합격으로도 판정하지 않습니다. diff --git a/test-docs/results/fixed-delay-20ms/7-sessions.md b/test-docs/results/fixed-delay-20ms/7-sessions.md index 46f6acf..ba7c5ed 100644 --- a/test-docs/results/fixed-delay-20ms/7-sessions.md +++ b/test-docs/results/fixed-delay-20ms/7-sessions.md @@ -3,17 +3,18 @@ ## 대표 상세 실행 정보 - 클라이언트 원본: `fixed-delay-20ms-7-no-diagnostic.json` / `.csv` +- 원본 검증값: [artifact-manifest.md](artifact-manifest.md) - 시작: 2026-07-17 02:12:36 KST - 종료: 2026-07-17 02:18:13 KST - 전체 실행 시간: 337.31초 - 입력: `720p30.ivf` (1280×720, VP8, 30FPS) - 안정 구간: 최초 30초 이후 표본 - 별도 health/ping 트래픽: 사용하지 않음 -- 결과: **통과** +- 결과: **클라이언트 기준 통과, 서버 기준 미측정** ## 전체 반복 실행 요약 -| 실행 | 시작 | 평균 수신 FPS | 최저 세션 평균 FPS | 평균/최대 프레임 차이 | RTT p95 최대 | 최종 연결 | 판정 | +| 실행 | 시작 | 평균 수신 FPS | 최저 세션 평균 FPS | 평균/최대 프레임 차이 | RTT p95 최대 | 최종 연결 | 클라이언트 판정 | | --- | --- | ---: | ---: | ---: | ---: | ---: | --- | | `fixed-delay-20ms-7-no-diagnostic` | 07-17 02:12:36 | 29.10 | 28.77 | 2.36% / 3.08% | 26.95ms | 7/7 | 통과 | @@ -56,4 +57,5 @@ 모든 세션이 평균 수신 27FPS를 넘었습니다. 최대 프레임 차이는 3.08%, RTT p95 최댓값은 26.95ms였으며 최종 PeerConnection과 ICE 상태는 모두 `connected`였습니다. 후순위 클라이언트의 0FPS 값은 순차 연결 직후의 초기 속도 표본이며 지속 정지가 -아닙니다. +아닙니다. 같은 시점 서버 지표가 없으므로 서버 FPS·오류율·큐 기준은 측정 보류이며, +이 실행을 전체 합격이나 최대 안정 용량으로 확정하지 않습니다. diff --git a/test-docs/results/fixed-delay-20ms/8-sessions.md b/test-docs/results/fixed-delay-20ms/8-sessions.md index dbcaeb6..8851e09 100644 --- a/test-docs/results/fixed-delay-20ms/8-sessions.md +++ b/test-docs/results/fixed-delay-20ms/8-sessions.md @@ -3,24 +3,27 @@ ## 대표 상세 실행 정보 - 클라이언트 원본: `fixed-delay-20ms-8-final-with-server-metrics.json` / `.csv` -- 서버 원본: `loadtest/server-results/fixed-delay-20ms-8-final/` (Git 제외) +- 서버 원본: [비공개 리소스 저장소의 불변 경로](https://github.com/team-framework/server-load-test-resources/tree/39c24c97218c28c74e01a5a938037506383d6f7c/results/2026-07-16-17/fixed-delay-20ms/server/fixed-delay-20ms-8-final) +- 원본 검증값: [artifact-manifest.md](artifact-manifest.md) +- 원본 공개 상태: 비공개 저장소, 접근 권한이 있는 리뷰어 검증 가능 - 시작: 2026-07-17 02:29:13 KST - 종료: 2026-07-17 02:34:56 KST - 전체 실행 시간: 343.27초 - 입력: `720p30.ivf` (1280×720, VP8, 30FPS) - 안정 구간: 최초 30초 이후 표본 - 별도 health/ping 트래픽: 사용하지 않음 -- 결과: **통과** +- 결과: **대표 실행 통합 기준 통과** ## 전체 반복 실행 요약 -| 실행 | 시작 | 평균 수신 FPS | 최저 세션 평균 FPS | 평균/최대 프레임 차이 | RTT p95 최대 | 최종 연결 | 판정 | +| 실행 | 시작 | 평균 수신 FPS | 최저 세션 평균 FPS | 평균/최대 프레임 차이 | RTT p95 최대 | 최종 연결 | 판정 범위 | | --- | --- | ---: | ---: | ---: | ---: | ---: | --- | -| `fixed-delay-20ms-8-clients-5m` | 07-16 23:00:19 | 28.05 | 27.68 | 5.68% / 6.33% | 218.81ms | 8/8 | 통과 | -| `fixed-delay-20ms-8-no-diagnostic` | 07-17 02:18:16 | 29.09 | 28.69 | 2.29% / 3.01% | 35.02ms | 8/8 | 통과 | -| `fixed-delay-20ms-8-final-with-server-metrics` | 07-17 02:29:13 | 28.49 | 28.18 | 4.25% / 4.98% | 41.47ms | 8/8 | 통과 | +| `fixed-delay-20ms-8-clients-5m` | 07-16 23:00:19 | 28.05 | 27.68 | 5.68% / 6.33% | 218.81ms | 8/8 | 클라이언트 통과, 서버 미측정 | +| `fixed-delay-20ms-8-no-diagnostic` | 07-17 02:18:16 | 29.09 | 28.69 | 2.29% / 3.01% | 35.02ms | 8/8 | 클라이언트 통과, 서버 미측정 | +| `fixed-delay-20ms-8-final-with-server-metrics` | 07-17 02:29:13 | 28.49 | 28.18 | 4.25% / 4.98% | 41.47ms | 8/8 | 통합 기준 통과 | -- 전체 성공률: 3/3 (100%) +- 클라이언트 기준 통과율: 3/3 (100%) +- 동시점 서버 지표 포함 통합 기준 통과: 1/1 (나머지 2회 서버 기준 미측정) - 성공 실행 평균 수신 FPS: 28.54 - 성공 실행 평균 프레임 차이: 4.07% - 성공 실행별 최대 프레임 차이 평균: 4.77% @@ -74,7 +77,8 @@ ## 판정 -클라이언트와 서버의 모든 기준을 통과했습니다. 5분 fixed-delay 예비 용량 시험에서 -**최소 8개 동시 세션**을 처리할 수 있음을 확인했습니다. 8은 계단식 체크포인트이지 -측정된 최대치가 아닙니다. 최대 안정 용량을 확정하려면 더 높은 부하에서 경계를 찾고 -30분 지속 시험을 진행해야 합니다. +클라이언트 기준은 3회 모두 통과했고, 서버 지표를 동시에 수집한 1회는 서버 기준도 +통과했습니다. 따라서 5분 fixed-delay 예비 시험에서 **8개 동시 세션 체크포인트를 +1회 통합 검증**했습니다. 나머지 2회에는 서버 지표가 없어 전체 기준 반복 통과로 +계산하지 않습니다. 8은 측정된 최대치가 아니며, 최대 안정 용량을 확정하려면 모든 +실행에서 동시점 서버 지표를 수집하고 경계 탐색과 30분 지속 시험을 진행해야 합니다. diff --git a/test-docs/results/fixed-delay-20ms/artifact-manifest.md b/test-docs/results/fixed-delay-20ms/artifact-manifest.md new file mode 100644 index 0000000..86bfd05 --- /dev/null +++ b/test-docs/results/fixed-delay-20ms/artifact-manifest.md @@ -0,0 +1,79 @@ +# fixed_delay 20ms 원본 매니페스트 + +이 문서는 2026-07-16~17 시험 결과 문서에 사용한 원본을 식별합니다. SHA-256은 +2026-07-17에 시험 PC의 원본에서 계산했고, 업로드된 Git blob이 로컬 원본과 바이트 +단위로 일치하는지 확인했습니다. + +기준 입력 영상과 아래 원본은 비공개 +[`team-framework/server-load-test-resources`](https://github.com/team-framework/server-load-test-resources/tree/39c24c97218c28c74e01a5a938037506383d6f7c) +저장소의 불변 커밋 `39c24c97218c28c74e01a5a938037506383d6f7c`에 있습니다. + +- 클라이언트 원본: [`results/2026-07-16-17/fixed-delay-20ms/client/`](https://github.com/team-framework/server-load-test-resources/tree/39c24c97218c28c74e01a5a938037506383d6f7c/results/2026-07-16-17/fixed-delay-20ms/client) +- 서버 원본: [`results/2026-07-16-17/fixed-delay-20ms/server/fixed-delay-20ms-8-final/`](https://github.com/team-framework/server-load-test-resources/tree/39c24c97218c28c74e01a5a938037506383d6f7c/results/2026-07-16-17/fixed-delay-20ms/server/fixed-delay-20ms-8-final) + +## 클라이언트 원본 + +| 파일 | 크기(byte) | SHA-256 | +| --- | ---: | --- | +| `baseline-fixed-delay-20ms-5m.csv` | 10,711 | `76D1DCCF4598A9CA7DCB73D07FB07F98F7970F366168AEA403A5A842A7509342` | +| `baseline-fixed-delay-20ms-5m.json` | 38,805 | `C32C6BCCD193861500A6E6A9CF63B6A846A12CCA942C20C84CB83D1C5B34BC8B` | +| `fixed-delay-20ms-2-clients-5m.csv` | 21,342 | `E780573089E4C51A3D4ADE90ED58A2DA7C3EA3359D7E60C175CAD717BB60B859` | +| `fixed-delay-20ms-2-clients-5m.json` | 77,628 | `2776BAD6E0080CB729F8E7B8BC0F48409C2A939DC20C06362590A493B265BE34` | +| `fixed-delay-20ms-4-clients-5m.csv` | 43,206 | `B5AC99AD69FC0D1426EAC96DD7064379E0B9028CCCC89D258ABEA2DC048E7072` | +| `fixed-delay-20ms-4-clients-5m.json` | 157,284 | `D774A67FD6F121ACDBDCF00F705F0FA3A0B797A3E4BB0B8EE3AB7170AEFDE66A` | +| `fixed-delay-20ms-6-back-to-back-diagnostic.csv` | 65,675 | `6BC8E0E0533017D6C8F16E692BE148E0180C409D474CD4EFF03FC9DBE04A3ED6` | +| `fixed-delay-20ms-6-back-to-back-diagnostic.json` | 240,258 | `D05E73AC4B0C3AF23D5BBF9165675F4A04B048C71E309AF3436FCCF357081C2D` | +| `fixed-delay-20ms-6-back-to-back-diagnostic2.csv` | 65,687 | `839DB6FA7605BA598F9D62A38C65F872ED4C27156FEC90606366B9C7BD1C8800` | +| `fixed-delay-20ms-6-back-to-back-diagnostic2.json` | 239,501 | `DDA3F1731BBCCF86AC24091389636801372C426757041A3BC7C2CFFF19B6F314` | +| `fixed-delay-20ms-6-back-to-back-diagnostic3.csv` | 65,692 | `CDB898509F23EFC92997D66D5DBEA5058AC0D62E2F41E2BB6FD8FF96D1DD7033` | +| `fixed-delay-20ms-6-back-to-back-diagnostic3.json` | 240,526 | `C5AA596ED07C50864EB3A91BF57CC481E93470C5C8DBE115C3A228F0C2B8F40D` | +| `fixed-delay-20ms-6-clients-5m.csv` | 67,844 | `B3019AE84072BCA5F948594832A827B50D1353EC2B21C2F01F4C874014337099` | +| `fixed-delay-20ms-6-clients-5m.json` | 246,360 | `F7B347A4BD55CD5A17C9E26BB7E3DB3610AB2A4559C975E3511CC9B28C5FF3D9` | +| `fixed-delay-20ms-6-clients-stateful-no-profiler.csv` | 68,036 | `E8EB9BD9F75DE50AEC504C8F397581CBC99569CC54E37A536F66DDD6B00088FA` | +| `fixed-delay-20ms-6-clients-stateful-no-profiler.json` | 244,692 | `FB41F1B4ECFB495C8D2B1F730DCD7FA1A038ED3C2CCCFC499EFD0C4942AF8108` | +| `fixed-delay-20ms-6-network-diagnostic.csv` | 65,700 | `98FDE82F700C279F0141718BC7A35E724E1193D4492AEE9AAC0C4D94D723E0B8` | +| `fixed-delay-20ms-6-network-diagnostic.json` | 241,617 | `A8C4DC7FB3842A08B6B8A42516EA49F20650B34B958F63F49BE3787CE031CD53` | +| `fixed-delay-20ms-6-no-diagnostic-traffic.csv` | 65,574 | `9D248FE9E0D1053A10D4DC1B6D0E3D3969CBB39BEAFC7A54FA9942E2D19CBBBA` | +| `fixed-delay-20ms-6-no-diagnostic-traffic.json` | 241,872 | `7D6D4133AB33C1872CD39E4F01ABBC0363E61E06985117F23AF1CD8A5DF709D1` | +| `fixed-delay-20ms-7-no-diagnostic.csv` | 77,060 | `6CA9A25353C4088B9A19AB2D8D36AB019D4F9FB11211253DA6BCCDF63C0C16D2` | +| `fixed-delay-20ms-7-no-diagnostic.json` | 282,340 | `61B5D2A1B38BCDE6C1C300C4A23F254A16510DA6BCB3582774170FEE6ADA54C4` | +| `fixed-delay-20ms-8-clients-5m.csv` | 89,054 | `F75AB8B11F57B854456E1A050D137E7B038AA05CB945782A0F8A62CD52080EEC` | +| `fixed-delay-20ms-8-clients-5m.json` | 324,723 | `BA84E3C620C2DBCFC269CD3766155C4981F1703C8533BD11354F08797AD34E15` | +| `fixed-delay-20ms-8-final-with-server-metrics.csv` | 88,825 | `AA829003C110DB194A46007016CB4FA36CA2E2FCDFCE3ACAA7AD493D905CDDC6` | +| `fixed-delay-20ms-8-final-with-server-metrics.json` | 324,801 | `90E35141C36E14B107D9B4CFAF3B822B8950B24F264AB8C1BB6ADEE93A948F95` | +| `fixed-delay-20ms-8-no-diagnostic.csv` | 88,723 | `00107854D0F82D92E3E8D48D43BDB9365A9D6D04B73C06B1FE761EE954A580FC` | +| `fixed-delay-20ms-8-no-diagnostic.json` | 329,057 | `2843F35E661D0F336733E783CFF83C629087EAFBC42F21855EF597C1595F3899` | +| `fixed-delay-20ms-fresh-server-6-compare.csv` | 69,569 | `F1B1D4E7B2C04485DE87F4F3D9CB25A6DED290643DFEC9722DFDF47C23AB506A` | +| `fixed-delay-20ms-fresh-server-6-compare.json` | 246,939 | `98980D7D6C3E8C56D27BA9111BC18E56D9ED89CDA9E9DF1DFDD4C124AA349837` | +| `fixed-delay-20ms-stateful-1-after-disconnect-5m.csv` | 10,682 | `713EB17AE04ADDA748E8CE1847A2BA2AECDA7A511AD0426FC42060B98A179716` | +| `fixed-delay-20ms-stateful-1-after-disconnect-5m.json` | 38,792 | `C34F95A0B461D0DC2D3581D6670F3D19C887DD8A74816D383B390A18EEB6EEF3` | +| `fixed-delay-20ms-stateful-4-after-disconnect-5m.csv` | 43,120 | `4E726DFF25BD1F2C519555E153523FA019D8B9C053E5005557AA743055D95EEE` | +| `fixed-delay-20ms-stateful-4-after-disconnect-5m.json` | 157,296 | `32FC0D7991968AA1D47271B4292166AED57DC31BD24A8176FD1240058A800694` | +| `fixed-delay-20ms-stateful-5-boundary-5m.csv` | 54,639 | `53193F627EC0294A144EC53D416BCC11933B243D9BB4E57C730F7AFAAAF78909` | +| `fixed-delay-20ms-stateful-5-boundary-5m.json` | 199,383 | `87A4C61EC11E81C3641B1863B5F011A94A023BA622B35A2070215313AFF65B2E` | +| `fixed-delay-20ms-stateful-6-repeat-1.csv` | 67,490 | `23E3E522CFDBE696C74E588360C0AD407520D48828258C5782639FD28A8DB16B` | +| `fixed-delay-20ms-stateful-6-repeat-1.json` | 247,764 | `828A2193430ECE01CFAC7B30C636C6DF4CF2BC78C78B87EF1CF8D50E4B5755E2` | +| `fixed-delay-20ms-stateful-6-repeat-2.csv` | 71,477 | `A234C9ECAD9EB42FD9B4620F88C26CA46D9EA04039529A169DF0216798A4321B` | +| `fixed-delay-20ms-stateful-6-repeat-2.json` | 261,784 | `23C820C7DB67DFB116997FAE38B926EF502ED6C366815E0B8EC3F18C3E05DE38` | + +## 8세션 서버 원본 + +로컬 경로: `loadtest/server-results/fixed-delay-20ms-8-final/` + +| 파일 | 크기(byte) | SHA-256 | +| --- | ---: | --- | +| `app.csv` | 28,095 | `1BA1CA7B57C7D7C3DA183ECFD4FA9AB7DD4EB2FFF5FF21080B8FEB9504D66EDD` | +| `health-final.json` | 69 | `5936C07B4C0D45415F51B830B3E607724DD20A0A0823207861BFA8C4206A0825` | +| `health-initial.json` | 69 | `5936C07B4C0D45415F51B830B3E607724DD20A0A0823207861BFA8C4206A0825` | +| `metrics-final.prom` | 6,355 | `07B2F92BF1889F8F61A7925AED27D7872E50493C2B7F60C12A77AD6DD2C0CE1D` | +| `metrics-initial.prom` | 6,352 | `EEC576FBA9F17C87F8D9E349E6ABA0E9D95E081DAAE2A57D6717CA01F5E5056D` | +| `sessions-final.json` | 20 | `6254B38AB0F4A29C5B05C0319B3CBF2EA4F0A655F2D14B52E3D3842D9ADCD877` | + +## 검증 방법 + +저장소 접근 권한이 있는 리뷰어는 위 불변 커밋에서 파일을 받은 뒤 다음 명령으로 +매니페스트와 비교할 수 있습니다. + +```powershell +Get-FileHash -Algorithm SHA256 -LiteralPath ".\파일명" +``` diff --git a/test-docs/test-backlog.md b/test-docs/test-backlog.md index 3640ece..ccb7ada 100644 --- a/test-docs/test-backlog.md +++ b/test-docs/test-backlog.md @@ -67,7 +67,10 @@ ## 우선순위 4: Python/Go 언어 비교 기준선 - [x] Python fixed_delay 20ms 단일 세션 5분 기준선 -- [x] Python fixed_delay 20ms 다중 세션 반복 및 최소 8세션 처리 확인 +- [x] Python fixed_delay 20ms 다중 세션 반복 및 8세션 체크포인트 예비 검증 +- [x] fixed-delay JSON/CSV와 서버 수집 원본을 비공개 리소스 저장소에 업로드하고 + `artifact-manifest.md`에 불변 커밋 링크 추가 +- [ ] 6·7세션과 8세션 반복 실행에서 동시점 서버 FPS·오류율·큐·정리 지표 재수집 - [ ] Python bypass `1 → 2 → 4 → 8` 세션 5분 기준선 - [ ] Python bypass 조건을 반복해 편차 기록 - [ ] 동일 REST·WebSocket·WebRTC 계약의 Go/Pion 서버 구현 diff --git a/test-docs/test-plan.md b/test-docs/test-plan.md index c6482e6..98df7f9 100644 --- a/test-docs/test-plan.md +++ b/test-docs/test-plan.md @@ -525,7 +525,9 @@ A와 C 모두 Go가 크게 빠름 E2E p50/p95/p99는 타임코드 또는 RTP 메타데이터 측정 구현 전까지 판정에서 보류한다. `1 → 2 → 4 → 8`은 최대 세션 수를 미리 8로 제한하는 값이 아니라 한계 구간을 빠르게 -찾기 위한 계단식 체크포인트다. 8세션이 통과하면 결과는 “최소 8세션”으로 기록하고, +찾기 위한 계단식 체크포인트다. 같은 실행에서 클라이언트와 서버 기준을 모두 측정해 +8세션이 통과하면 “8세션 체크포인트 통합 통과”로 기록한다. 반복 실행에서도 통합 기준이 +재현되고 30분 지속 기준을 만족하기 전에는 “최소 안정 8세션”으로 확정하지 않는다. 최대치가 필요한 경우 12·16세션으로 확대한 뒤 최초 실패 구간을 1세션 단위로 좁힌다. InnoLive가 “초저지연”을 핵심 가치로 내세우고 있으므로, 단순히 서버가 죽지 않는지를 기준으로 하면 안 된다. 지연과 프레임 드롭, 비식별화 정상 동작을 모두 만족해야 수용 가능한 세션으로 계산해야 한다. diff --git a/test-docs/test-result.md b/test-docs/test-result.md index 2229a03..128fc81 100644 --- a/test-docs/test-result.md +++ b/test-docs/test-result.md @@ -70,32 +70,46 @@ ## Python fixed_delay 20ms 다중 세션 예비 용량 -2026-07-16~17에 같은 Python/aiortc 서버 프로세스와 `720p30.ivf`를 사용해 -`fixed_delay 20ms`를 1·2·4·5·6·7·8세션으로 측정했습니다. 각 실행은 5분이며 첫 +2026-07-16~17에 동일한 Python/aiortc 코드·설정·하드웨어와 `720p30.ivf`를 사용해 +`fixed_delay 20ms`를 1·2·4·5·6·7·8세션으로 측정했습니다. 시험 도중 서버를 +재시작한 구간이 있으므로 모든 실행이 하나의 서버 프로세스에서 이어진 것은 아닙니다. +각 실행은 5분이며 첫 30초 이후의 클라이언트 표본을 집계했습니다. 반복 실행을 대표 성공 1건으로 축약하지 않고 모두 기록합니다. 아래 정상 평균은 모든 세션이 FPS·프레임 차이·연결 기준을 만족한 실행만 집계한 값이며, 선택 편향을 피하기 위해 전체 성공률을 함께 표시합니다. -| 동시 세션 | 성공/전체 실행 | 성공 실행 평균 수신 FPS | 성공 실행 평균 프레임 차이 | 실행별 최대 차이 평균 | 실행별 RTT p95 최댓값 평균 | 반복 판정 | -| ---: | ---: | ---: | ---: | ---: | ---: | --- | -| [1](results/fixed-delay-20ms/1-session.md) | 2/2 | 29.68 | 1.04% | 1.04% | 25.83ms | 통과 | -| [2](results/fixed-delay-20ms/2-sessions.md) | 1/1 | 29.57 | 1.48% | 1.53% | 34.93ms | 통과, 반복 필요 | -| [4](results/fixed-delay-20ms/4-sessions.md) | 2/2 | 29.34 | 2.22% | 2.67% | 77.87ms | 통과 | -| [5](results/fixed-delay-20ms/5-sessions.md) | 0/1 | - | - | - | - | 네트워크 이상 실패 1건 | -| [6](results/fixed-delay-20ms/6-sessions.md) | 5/10 | 29.24 | 2.03% | 2.31% | 98.51ms | 정상 처리 가능, 공인망 반복성 50% | -| [7](results/fixed-delay-20ms/7-sessions.md) | 1/1 | 29.10 | 2.36% | 3.08% | 26.95ms | 통과, 반복 필요 | -| [8](results/fixed-delay-20ms/8-sessions.md) | 3/3 | 28.54 | 4.07% | 4.77% | 98.43ms | 통과 | +| 동시 세션 | 클라이언트 통과/전체 | 성공 실행 평균 수신 FPS | 성공 실행 평균 프레임 차이 | 실행별 최대 차이 평균 | 실행별 RTT p95 최댓값 평균 | 동시점 서버 검증 | 판정 | +| ---: | ---: | ---: | ---: | ---: | ---: | --- | --- | +| [1](results/fixed-delay-20ms/1-session.md) | 2/2 | 29.68 | 1.04% | 1.04% | 25.83ms | 1개 수집 구간 | 클라이언트 통과, 서버 근거 있음 | +| [2](results/fixed-delay-20ms/2-sessions.md) | 1/1 | 29.57 | 1.48% | 1.53% | 34.93ms | 1개 수집 구간 | 통합 기준 통과, 반복 필요 | +| [4](results/fixed-delay-20ms/4-sessions.md) | 2/2 | 29.34 | 2.22% | 2.67% | 77.87ms | 1개 수집 구간 | 클라이언트 통과, 서버 근거 있음 | +| [5](results/fixed-delay-20ms/5-sessions.md) | 0/1 | - | - | - | - | 없음 | 네트워크 이상 실패 1건 | +| [6](results/fixed-delay-20ms/6-sessions.md) | 5/10 | 29.24 | 2.03% | 2.31% | 98.51ms | 대표 실행 없음 | 클라이언트 정상 처리 가능, 서버 기준 미측정 | +| [7](results/fixed-delay-20ms/7-sessions.md) | 1/1 | 29.10 | 2.36% | 3.08% | 26.95ms | 없음 | 클라이언트 통과, 서버 기준 미측정 | +| [8](results/fixed-delay-20ms/8-sessions.md) | 3/3 | 28.54 | 4.07% | 4.77% | 98.43ms | 1/3 실행 | 통합 기준 1회 통과, 클라이언트 3/3 통과 | 동시 세션 수 링크에는 해당 세션 수로 수행한 모든 실행의 이름과 판정, 정상 실행 평균, 대표 실행의 클라이언트별 FPS·누적 프레임·RTP 손실, jitter·RTT 분포, 최종 연결 상태 및 수집 가능한 서버 지표를 기록했습니다. 성공 실행 평균은 실패를 숨기는 -전체 평균이 아니며 반드시 `성공/전체 실행`과 함께 해석합니다. +전체 평균이 아니며 반드시 `클라이언트 통과/전체`와 함께 해석합니다. + +판정은 두 수준으로 구분합니다. + +- **클라이언트 통과**: 모든 클라이언트가 평균 수신 27FPS 이상, 프레임 차이 10% + 이하, 최종 연결 성공을 만족합니다. +- **통합 기준 통과**: 클라이언트 통과와 같은 실행에서 서버 처리 FPS 27 이상, + stale drop 10% 이하, 처리 오류율 0.1% 이하, 큐 비증가 및 종료 정리까지 확인합니다. + +동시점 서버 지표가 없는 실행은 클라이언트 통과로만 표시하며 전체 합격을 주장하지 +않습니다. 처리 오류율은 `처리 오류 / 서버 수신 프레임 × 100`으로 계산하며, 오류가 +한 건이라도 있으면 실패하는 것이 아니라 0.1% 초과일 때 실패합니다. 집계 순서는 다음과 같습니다. 먼저 최초 30초 이후 각 클라이언트의 평균을 계산하고, 이를 산술 평균해 실행 평균을 만듭니다. 정상 평균은 통과 실행들의 실행 평균을 다시 동일 가중치로 산술 평균합니다. 한 클라이언트라도 평균 수신 27FPS 미만, 프레임 차이 -10% 초과, 최종 연결 실패 또는 처리 오류가 있으면 해당 실행 전체를 실패로 분류합니다. -실패 실행은 정상 평균에서는 제외하지만 전체 실행 수와 성공률에서는 제외하지 않습니다. +10% 초과 또는 최종 연결 실패가 있으면 클라이언트 실패로 분류합니다. 서버 지표가 있는 +실행은 서버 FPS, stale drop, 처리 오류율, 큐와 종료 정리를 별도로 판정합니다. 실패 +실행은 정상 평균에서는 제외하지만 전체 실행 수와 성공률에서는 제외하지 않습니다. 최종 8세션 실행에서는 Windows 호스트 수집기로 서버 메트릭도 함께 기록했습니다. 8세션이 모두 활성인 303.4초 동안 69,545프레임을 처리해 전체 229.22FPS, 세션당 @@ -103,12 +117,13 @@ 0건이었습니다. 평균 CPU는 약 4.13코어, 최대 RSS는 약 1.66GiB였고 처리 큐는 시험 내내 0이었습니다. 종료 후 활성 세션과 큐도 모두 0이었습니다. -따라서 네트워크가 정상인 구간의 5분 예비 용량 시험 기준으로 **Python fixed_delay의 -확인 처리 용량은 최소 8세션**입니다. 다만 공인망 전체 반복성은 특히 6세션에서 -5/10이므로 이를 곧바로 End-to-End 최대 안정 세션으로 부르지 않습니다. 8은 계단식 -시험 체크포인트이며 최대치가 아닙니다. 최종 최대 안정 세션을 주장하려면 통제된 LAN -또는 TURN 경로에서 더 높은 부하의 경계를 찾고 후보 세션 수를 30분 이상 유지해야 -합니다. +따라서 네트워크가 정상인 구간의 5분 예비 시험에서 8세션은 클라이언트 기준 3/3을 +통과했고, 그중 서버 지표를 함께 수집한 1회는 통합 기준도 통과했습니다. 이 결과는 +**Python fixed_delay 8세션 체크포인트의 예비 처리 가능성**을 뒷받침하지만, 서버 +기준의 반복 통과나 End-to-End 최대 안정 세션을 확정하지는 않습니다. 공인망 전체 +반복성은 특히 6세션에서 5/10입니다. 최종 최대 안정 세션을 주장하려면 통제된 LAN +또는 TURN 경로에서 동시점 서버 지표를 매번 수집하고, 더 높은 부하의 경계를 찾은 뒤 +후보 세션 수를 30분 이상 유지해야 합니다. ### 비단조 실패 실행과 네트워크 판정 @@ -118,6 +133,12 @@ 연속으로 성공했고 7세션 종료 2.884초 뒤 시작한 8세션도 통과했습니다. health/ping 진단 트래픽을 끈 6·7·8세션도 모두 통과했습니다. +실행 경계는 결과 이름에 보존했습니다. `stateful-*` 실행들은 기존 프로세스 상태를 +유지했고, `fresh-server-6-compare`는 서버 재시작 직후 수행했습니다. 이후 성공 실행은 +같은 코드와 설정을 사용했지만 모든 실행이 하나의 프로세스에서 연속 수행된 것은 +아닙니다. 따라서 재시작 여부만으로 성공과 실패를 설명할 수 없다는 범위까지만 +해석합니다. + 이 비단조 실패는 서버의 고정 용량 경계, 누적 상태, profiler 부하 또는 cooldown으로 설명되지 않습니다. 현재 가장 가능성 높은 원인은 공인 인터넷 구간의 일시적인 WebRTC UDP/NAT 경로 이상입니다. 실패 원본도 세션별 상세 문서와 전체 성공률에 포함합니다. @@ -125,6 +146,21 @@ UDP/NAT 경로 이상입니다. 실패 원본도 세션별 상세 문서와 전 통제되지 않은 네트워크 실패를 별도 표시합니다. 최종 용량 비교는 가능하면 같은 LAN 또는 통제된 TURN 경로에서 반복합니다. +### 원본 보관 및 재현성 + +- 기준 입력 영상과 fixed-delay 클라이언트 JSON/CSV 40개, 8세션 서버 수집 원본 + 6개는 비공개 + [`team-framework/server-load-test-resources`](https://github.com/team-framework/server-load-test-resources/tree/39c24c97218c28c74e01a5a938037506383d6f7c) + 저장소의 불변 커밋 `39c24c97218c28c74e01a5a938037506383d6f7c`에 보관합니다. +- 시험 원본 경로는 + [`results/2026-07-16-17/fixed-delay-20ms/`](https://github.com/team-framework/server-load-test-resources/tree/39c24c97218c28c74e01a5a938037506383d6f7c/results/2026-07-16-17/fixed-delay-20ms) + 이며 저장소 접근 권한이 있는 리뷰어가 내려받아 재계산할 수 있습니다. +- 현재 보유한 원본의 파일 크기와 SHA-256은 + [artifact-manifest.md](results/fixed-delay-20ms/artifact-manifest.md)에 기록했습니다. + 업로드된 파일과 로컬 원본의 동일성을 확인할 수 있습니다. +- 문서의 수치는 해당 해시의 원본으로 계산했습니다. 저장소가 비공개이므로 접근 권한이 + 없는 제3자의 독립 검증은 제한되지만, PR 리뷰어에게 권한이 있다면 재현 가능합니다. + ## Python 단일 세션 5분 기준선 첫 30초는 안정화 구간으로 제외하고 이후 4분 30초를 집계합니다. @@ -201,7 +237,7 @@ CPU/GPU 동기화 비용의 영향이 큰 상태입니다. | 항목 | Python | Go | 차이 | | --- | --- | --- | --- | -| 최대 연결 가능 세션 | 최소 8 (fixed_delay 예비 시험) | | | +| fixed_delay 예비 체크포인트 | 클라이언트 8세션 3/3, 통합 기준 1/1 | | | | 최대 안정 세션 | 미확정 (30분 지속 시험 전) | | | | 운영 권장 세션 | | | | | 평균 처리 FPS | | | | diff --git a/test-docs/test-runbook.md b/test-docs/test-runbook.md index 7b88d96..f113567 100644 --- a/test-docs/test-runbook.md +++ b/test-docs/test-runbook.md @@ -76,6 +76,14 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass ` The Windows collector supports `bypass`, `fixed_delay`, and `real`. It waits up to 600 seconds for the first active session, then saves per-second counters, initial/final Prometheus snapshots, health, and final session cleanup state. +It also waits up to 10 seconds for the selected mode's received/processed frame +series. Missing required metrics stop the collection instead of being recorded +as zero; only absent drop/failure counter series are treated as zero. + +Use `client pass` only for FPS, frame-gap, and connection checks derived from the +Pion result. Use `combined pass` only when the same run also captures server FPS, +stale drop, failure rate, queue behavior, and cleanup. A client-only pass must not +be reported as a full capacity pass. Judge client send/receive frame gap and server stale drop independently. They observe overlapping loss in different parts of the pipeline and must not be