fsp-server: report capability separately from duty cycle

The rate line was bytes/elapsed, which is not the capability of the path: the
game reads in bursts, so any window containing idle time understates what the
link can do.  Dennis spotted this -- 593 KB/s observed while the per-round-trip
arithmetic says ~700 -- and the fix belongs in the tool, not in the reader's
head.

Now reports, every two seconds:

  average   bytes / elapsed, including idle   (what the game happened to want)
  CAPABLE   bytes / time ACTUALLY serving     (what the path can do)
  duty      fraction of the window streaming
  round trip median gap, split into total and this server's service time,
            plus the implied ceiling (reply_size / RTT) at that round trip

Consecutive requests closer together than 10 ms count as streaming (the round
trip is ~2 ms); longer gaps are the game not asking and are excluded from the
capability figure.  A synthetic burst model confirms the split: 1440 B every
2 ms in bursts with 200 ms idle reports 352 KB/s average but 703 KB/s capable
at 50% duty, which is the distinction that matters when asking whether the
adapter is the limit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 16:45:14 +02:00
parent 4e412c8b9b
commit c887ab32fa
+39 -9
View File
@@ -311,6 +311,17 @@ def main():
uploads = {} # peer -> staged bytes, committed by CC_INSTALL
service = [] # per-request service time, to split the round trip
# ⚠️ A plain bytes/elapsed average is NOT the capability of the path: the
# game reads in bursts, so any window that contains idle time understates
# what the link can do. Tracking the gap between consecutive requests
# separates the two -- gaps under IDLE_GAP are the round trip (we are
# streaming), longer ones are the game not asking.
IDLE_GAP = 0.010
active_time = 0.0 # seconds actually spent streaming
active_bytes = 0
idle_time = 0.0
intervals = [] # inter-request gaps while active
t_prev = None
served = 0
t_first = None
t_report = time.time()
@@ -510,6 +521,15 @@ def main():
reads += 1
now = time.time()
service.append(now - t_req)
if t_prev is not None:
gap = now - t_prev
if gap <= IDLE_GAP:
active_time += gap
active_bytes += len(data)
intervals.append(gap)
else:
idle_time += gap
t_prev = now
if t_first is None:
t_first = now
print(f"[{peer[0]}] GET_FILE {path!r} streaming started")
@@ -520,16 +540,26 @@ def main():
el = max(now - t_first, 1e-9)
svc = sorted(service)
med = svc[len(svc) // 2] * 1000 if svc else 0.0
rtt = (el / reads) * 1000 if reads else 0.0
print(f" streaming {served/el/1024:7.0f} KB/s "
f"{served/2**20:8.1f} MiB in {el:6.1f}s "
f"{reads/el:5.0f} reads/s {len(data)} B/reply")
# One request in flight, so the observed interval IS the round
# trip. Splitting it shows whether WE are the slow part.
print(f" round trip {rtt:5.2f} ms, of which this server "
f"{med:5.2f} ms (median) -> "
f"{'SERVER-bound' if med > rtt/2 else 'console-bound'}")
iv = sorted(intervals)
rtt = iv[len(iv) // 2] * 1000 if iv else 0.0
duty = 100 * active_time / el
cap = (active_bytes / active_time / 1024) if active_time else 0
print(f" average {served/el/1024:7.0f} KB/s over {el:6.1f}s "
f"({served/2**20:7.1f} MiB, {len(data)} B/reply)")
# The capability figure: bytes served divided by the time
# actually spent serving, with idle gaps excluded. This is
# what the path can do; the average above is that times the
# duty cycle, i.e. how much the game happened to ask for.
print(f" CAPABLE {cap:7.0f} KB/s while streaming "
f"duty {duty:5.1f}% idle {idle_time:5.1f}s")
# One request in flight, so the median gap IS the round trip.
print(f" round trip {rtt:5.2f} ms median, of which this "
f"server {med:5.2f} ms -> "
f"{'SERVER-bound' if med > rtt / 2 else 'console-bound'}"
f"; ceiling at this RTT = "
f"{(len(data)/(rtt/1000))/1024 if rtt else 0:.0f} KB/s")
service.clear()
intervals.clear()
t_report = now
continue