Bandwidth profiler

The profiler breaks a room’s traffic down per field instead of giving you a total. It answers “61% of that is one str(64) field on one collection” rather than “this room does 40 kB/s”, so you know what to change.

Turn it on with irtio dev --profile, with simulate --profile, with joinRoom(schema, { profile: true }) in a browser, or with testRoom({ profile: true }) in a test. It is off everywhere by default, and off costs nothing: nothing is allocated, no frame is read twice, and the encoders are the same code either way.

Reading a table

This is a real one: dive, a co-op physics platformer, with two players, over a ten-second window.

room DIVE-7Q2M · 10.0 s window
  KIND        KEY                 OUT        IN  SHARE
  overhead    op             9.1 kB/s   472 B/s    37%
  overhead    delta-header   4.2 kB/s   472 B/s    17%
  field       players.x       961 B/s     0 B/s   3.9%
  correction  tail            961 B/s     0 B/s   3.9%
  field       players.y       639 B/s     0 B/s   2.6%
  field       players.vx      540 B/s     0 B/s   2.2%
  correction  players.x       480 B/s     0 B/s   2.0%
  field       props.wz        476 B/s     0 B/s   1.9%
  field       props.vx        468 B/s     0 B/s   1.9%
  field       shots.vx        419 B/s     0 B/s   1.7%
  total                     24.5 kB/s   1.2 kB/s
  socket                    24.5 kB/s   1.2 kB/s

OUT is what the server sent, IN is what it received, and SHARE is that row’s slice of OUT. The total line is the ledger’s own sum. The socket line is what was written to and read from the connection over the same window. Both are printed so you can compare them: if they ever disagreed, the ledger would be the one to distrust.

Rates are always derived from two cumulative readings and a window length. Nothing stores a rate, so a missed poll widens the window rather than losing the bytes.

Read that table by what is missing from it. Every field row is small, the biggest under 4%, and 55% of everything the room sends is overhead: the op framing that says which entity changed, and the per-frame delta header. Dive sends a great many small updates about a great many entities at 60 Hz, so it is paying for addressing rather than for data. Only a breakdown that counts the framing as its own row shows that.

The kinds

Every byte of every frame lands in exactly one row. The kind says what the byte was for; the key says which thing.

KindKeyWhat it counts
fieldcollection.fieldField values inside a state update. The steady state, and usually the answer
presenceclients.fieldThe built-in clients collection, split out of field so a room’s own state is not mixed with irtio’s
correctioncollection.field, and tailField values inside a server-wins correction, plus the tick suffix a correction carries
writecollection.fieldField values a client wrote to the server
churncollectionArea-of-interest enter and leave: the whole add or remove op for an entity that crossed a visibility boundary
rpcrpc nameCALL and its REPLY, both directions
messageall, client, role, serverroom.send / client.send traffic, by how it was addressed
voicesignalVoice signaling. Media never touches this socket, so this is signaling only
joincollection.field, and headerThe join snapshot, kept out of the steady-state rows so one join does not look like a leak
overheadenvelope, delta-header, opFraming: the frame type byte; tick, schema hash and counts; each op’s tag, id, owner and dirty mask
controlping, pong, hello, welcome, error, leave, schemaEverything else, whole frame

Reading overhead

overhead is the price of saying which thing changed rather than what it changed to. It is the row that tells you your updates are too small. op is a tag byte, an entity id as a string, and a dirty mask, around ten bytes for a short id. If op is a large share of OUT, you are sending many tiny updates. The fix is usually fewer, larger ones: a lower tick rate for that collection’s data, or fewer entities that change every tick.

delta-header is a fixed 12 bytes per frame, the tick and the schema hash, plus the per-collection framing. If it is significant, you are sending too many frames.

In the dive table above the two together are 55% of what the room sends. The lever is the tick rate for the collections that do not need 60 Hz, and after that the number of entities changing every tick. Neither is a codec problem.

Reading churn

churn is what a spatial-grid collection costs you in movement of the viewer, as opposed to movement of the world. When an entity crosses into a client’s area of interest, the server sends that entity’s whole record even though nothing about it changed. When it crosses out, a remove. Those bytes are neither a spawn nor an update, and counting them as either would hide the cost of your cell size.

The server tells churn from a real spawn exactly, because it does the encoding: a synthetic add is one the global dirty set never contained. Once encoded, the two are byte-identical, so a client cannot make that distinction. In the browser overlay the row is labelled enter/leave (incl. spawns) for that reason. High churn against low real spawn rates means your cells are too small or your radius too tight for how fast things move.

Reading join

A join is a full snapshot, so it is large and it happens once. It is separated out so that a table taken over a window that happened to contain a join does not read as though your steady state tripled. If join dominates a long-running room, players are reconnecting.

On the server

irtio dev --profile prints one table per room per second, plus --profile-top <n> for how many rows. The ledger also rides /__irt/state.json under each room’s profile, if you would rather read it with a script.

The room’s ledger is the room’s own view. It counts what the room sent and what the room accepted, plus the join snapshots it produced. The WELCOME frame is assembled outside the room, so the room sees the snapshot and not the envelope around it. PING and PONG are answered outside the room too, so they show up in a browser’s ledger and not in a room’s.

In a test

// irtio/room.test.ts
import { testRoom } from '@irtio/testing';
import room from './room.js';

const t = await testRoom(room, { profile: true });
await t.join(2);
await t.run(1000);

const top = t.profile!.rows.filter((r) => r.kind === 'field')[0];
expect(top.key).toBe('shots.owner');

t.profile is undefined in a room that was not asked to profile, never an empty ledger, because “nobody measured” and “nothing moved” are different answers.

In the browser

// main.ts
import { joinRoom } from '@irtio/client';
import { schema } from './irtio/schema.js';

const room = await joinRoom(schema, { profile: true });
room.profile?.perSecond();  // the last second, as rows
room.profile?.total();      // cumulative since join

room.profile exists only when you asked for it. The client’s ledger covers the whole connection, so it sees the WELCOME envelope, the pings, and everything the server’s ledger does.

For an on-screen version, @irtio/lobby ships <irt-profile>:

<irt-profile></irt-profile>
import '@irtio/lobby';   // registers the element

document.querySelector('irt-profile').room = room;

It renders the top rows per second, refreshed once a second, in the same style as <irt-status>. Mount it behind a flag rather than shipping it. Dive mounts it when the page URL carries ?profile.

Under simulate

irtio simulate --profile runs every bot with a ledger and prints one table for the whole run, divided per bot the way the rest of that report is, labelled as seen by the bots. That is the view from the outside: what a real client’s connection carries, rather than what the room believes it sent.

What it costs

A profiled room reads each frame it encodes one extra time, without decoding it. Values are skipped by width, nothing is allocated per value, and a payload shared by several clients is read once and attributed per recipient. A room that is not profiling constructs no ledger and reads nothing twice, and the encoders are byte-for-byte the same code in both cases. The profiler is a second reader of the wire, never an instrumented writer.

Measured on dive at 60 Hz with four players for thirty seconds, three runs each way: worst tick 9.6 ms with profiling off and 10.1 ms with it on, zero dropped ticks either way, and the same 2.05 MB sent. In the browser the overlay costs about a tenth of a frame per second at 60 Hz.

Next steps

  • Visibility for the cell size and radius behind the churn row
  • Simulated players for the bot run --profile attaches to
  • Limits for the allowances these bytes count against