Skip to content

Decode and snapshot cost

Measured on 2026-10-20 on testlab-co-channel: 743 s, 189,374 frames on one radio, 10,623 of them management frames (7,175 beacons of the U7 Pro Max), on the MacBook (Apple silicon), tshark 4.6.8. MeasureTests and DecodeMeasureTests (app guide).

Where the decode went

Part Debug before Debug after Release after
Whole decode (ReplayRecording.decode, both tshark passes in parallel) 97 s 28 s 25 s
tshark field pass alone 8 s 8 s 8 s
tshark JSON pass alone (-J "frame wlan.mgt", management frames) 25 s, 550 MB of JSON same same
Splitting the JSON into packets 97 s (in 64 KB pieces) 0.5 s 0.3 s
JSONSerialization of the packets 3 s 3 s 3 s
ManagementBody from the JSON tree ~76 s 9.6 s (from 47 s)
Frame(tsharkLine:), the field lines 14.5 s 14.5 s 4.0 s

A beacon of the U7 Pro Max is 61 KB of JSON; its elements 1 to 3 KB each. Two causes:

  • The JSON splitter built a String per JSON line and joined them, and its line splitter cut the buffer after every line, copying the rest each time. It now finds the packet boundaries in the bytes with memchr and cuts once per piece; the line splitter cuts once per piece as well.
  • Every field lookup in a JSON node walked its whole subtree, with the keys sorted at each level, and a beacon's element takes dozens of lookups. A node now indexes its subtree on the first lookup, in the same order.

The decode was then bound by tshark's JSON pass, which the next section takes on.

Fewer frames through the JSON pass

Measured on 2026-10-22 on the same recording and tshark, while three other sessions built and tested on the same Mac: a load average of 8 to 64 on its 10 cores. Before and after were measured in turn; tshark's CPU time is steadier than the wall clock.

The JSON tshark got every frame of the stream and dissected each with its full tree, also the 178,751 data and control frames its display filter then left out: some 5 of its 27 s of CPU time. Of the 10,623 management frames, 7,175 were beacons of the U7, and their body hardly changes: but for the timestamp and the TIM, 74 different ones in 743 s, mostly another BSS Load, a few another vendor element and extension element. In the 23 recordings 756 of 60,760 beacons and probe responses bring a body their transmitter had not sent before: the AP45 changes its body, mostly its BSS Load, every 10 to 23 beacons, the U7 every 55 to 97. Another 3,442 management frames were the client's sounding feedback, EHT compressed beamforming of some 1.7 KB each and 103 MB of JSON, of which the engine reads nothing.

One beacon per second per BSS with its elements, as planned, would have left the other beacons without what they carry: BSSTable takes the signal, the beacon gaps and the members of an MBSSID set from beacons with a body, and EMA spreads the profiles over several beacons. Instead the app reads the pcap records itself (ManagementSelection): a beacon or probe response that repeats one of the last 256 bodies decoded of the same transmitter and subtype, but for its timestamp, the TIM and the MME of beacon protection, takes that decode; sounding feedback gets no body; frames of other types do not go to the JSON tshark. Over the 23 recordings the JSON tshark gets 1,657 of their 69,031 management frames, and 7,370 sounding feedback frames get no body. Every beacon keeps its elements: the golden files stayed the same, and in five pcaps, two real recordings among them, each frame's body equals tshark's decode of that very frame. A body that changed goes again, with its BSS Load, the count of a channel switch, each part of an EMA set or the DTIM count in an MBSSID profile; the 256 bodies leave room for the APs of a context scan.

Part Before After
Frames to the JSON tshark 189,374, of them 10,623 management frames 82 (39 KB): 78 of the 7,177 beacons and probe responses, the 4 other management frames
tshark JSON pass alone, CPU time 27 s, 550 MB of JSON 0.25 s, 4.9 MB
Its Swift side: splitting, JSONSerialization and ManagementBody, release (debug) 14.7 s (30.4 s) 0.14 s (0.23 s)
Choosing the frames, over the whole stream, release (debug) 0.05 s (0.53 s)
Whole decode (ReplayRecording.decode), release, four times, the last two at a load of 8 to 12 34.1, 33.8, 29.0 and 25.9 s 8.7, 15.2, 8.4 and 8.3 s
Whole decode, debug, twice 31.2 and 28.9 s 17.8 and 17.7 s

The decode is now bound by the field pass: tshark's 8.3 to 8.9 s of CPU time, and Frame(tsharkLine:), 4.4 s in release. Live, the JSON tshark of a radio decodes a body when the AP changes it, 78 in the 743 s of this recording, where it decoded every beacon and every sounding feedback.

The test run

Measured on 2026-11-04 on an M1 Pro with ten cores, tshark 4.6.8, over the 97 pcaps of 58 recordings: 3.36 million frames. Each figure is one run with nothing to build, wall clock and then CPU, user and sys, with tshark in it. "Tests of make test" leaves out the recordings and the windows.

Build Tests of make test Recordings Clean build One file changed
Debug, as it was 108 s, 640 s 143 s, 1,127 s 62 s 6 s
-c release with -enable-testing, whole module 65 s, 319 s 43 s, 328 s 201 s 92 s
Debug with -O 67 s, 372 s 65 s, 522 s 89 s 6 s
Debug with -O, tshark's output cached 62 s, 282 s 72 s, 513 s
Debug with -O, the field lines read faster, no cache 42 s, 326 s
Debug with -O, the field lines read faster, cached 58 s, 274 s 25 s, 173 s
Debug without -O, the field lines read faster, cached 100 s, 558 s 139 s, 1,010 s 56 s

The clean build of the second row took 485 s of CPU, the third's 599 s. With both changes make test took 63 s and make test-golden 26.5 s, and make test-all, with tshark itself and the windows, 114 s. With MACAddress.init over the UTF-8 bytes as well, half of what was left of the field parse, 57.5 s and 21.8 s, of which swift test's look at the build some 5 s and 2 s; LinksideUITests, 36 s with LiveStoreTests serialized at 38 s, bounds make test now.

tshark was not what made the recordings slow. Its field pass alone over the 97 pcaps takes 127.5 s of CPU, the largest 15.4 s, and starting takes 0.09 s; in a run of the recordings its 208 processes took 191 s, in the tests of make test its 781 took 164 s. -O cut the Swift side by about two thirds, but cached output then gained nothing over tshark: 72 s against 65 s, with one run at 89 s and 705 s of CPU, though no tshark ran.

A sample of 20 s of the golden tests found the rest in Frame(tsharkLine:): nearly all active samples of the process, about half of them swift_retain and swift_release in Row.raw(_:). Each field of each frame looked its column up in one static dictionary, hashing the field's String raw value, and every thread retained the dictionary's storage, so ten cores waited on one reference count; the faster tshark delivered, the more of them did. A field's column now comes from how the enum is stored (TsharkColumns.column), and a line splits on its UTF-8 bytes.

Frame(tsharkLine:), one thread, testlab-co-channel, 189,374 frames Before After
Release 4.18 s, 22 µs a frame 2.65 s, 14 µs a frame
Debug with -O 3.49 s

Tried, and left out: noRecordTwice(_:) hashing a record without its bytes changed nothing measurable. Two swift test side by side, one per test bundle, took 59 s against 69 s before the field lines changed: each bundle runs longer beside the other, 57 s against 38 s for LinksideUITests, and a red run would read as two outputs interleaved.

Snapshot and seek

At Snapshot Overview from it Re-judged over the last 60 s Windows of the hints
25 % 6 ms 1 ms 1 ms 38
50 % 8 ms 0 ms 1 ms 75
75 % 31 ms 3 ms 10 ms 112
100 % 59 ms 2 ms 18 ms 149

Debug build; release takes 7 ms at 100 %. Playing the whole session forward in steps of a second, as playback and live do, takes 5.5 s (0.65 s release). A seek back to the middle replays from the start in 0.19 s (0.03 s release); from a copy of the analysis a minute before it, 2 ms. A copy, made each minute, is about 0.3 MB (release footprint). Checkpoints are not needed at this length: a session of an hour seeks back in about a second.

A live session of 51 minutes

On 2026-10-21 the app recorded 51 minutes live with both radios (testlab-long-quiet before the cut: 452,000 frames on capA, 34,000 on capB), the Release build, and got slow as the session grew. Its CPU, from ps every minute, was 55 to 70 % in the first half hour, 80 to 92 % from the 31st minute on and 148 % in the 46th; its memory went from 180 to about 400 MB. The four tshark processes grew with the frames they decoded, as tshark keeps state per frame for the whole session: the pair of capA from 93 to 172 MB in 39 minutes, that of capB to 104 MB.

Where the app's time went, measured on 2026-10-22 on two stand-ins, as the uncut recording is outside the repo: the four stretches of testlab-long-quiet laid end to end 41 times, 52 minutes with 504,336 frames on capA and 75,580 on capB and Mist's polls of its first 33 minutes (the pcap records shifted in time, without the status files, session.json and mist.json as they were); and testlab-power-save-video ten times in a row, 55 minutes with 475,520 and 31,610 frames and 110 Mist polls with 90 events (the pcap records, the status lines with their counters carried on, the clock measurements and the Mist polls shifted by the length of the recording per copy). Both built by a script outside the repo. Release build, MeasureTests/liveSeconds, panes and live() (app guide); live() plays the recording through the simulated sensor ten times as fast, so its frames come ten times as fast as live, its snapshots and refreshes once a second as live.

The scan of the windows

Each second live judges the whole session again, and in it every window of 20 s every 5 s: 627 windows at 52 minutes. Each window summed its metrics, Block Acks and TXOPs by going through every bucket of the session (total(in:), five to six times per window), summed the whole session's Block Acks again, built the handshakes, the EAP exchanges and the SAE failures from every event up to four times, parsed every Mist event again for its Mist view, and listed every data frame's MCS for the median. Now a window looks up its own buckets, finds its events and wakes by bisection while they are in time order, and takes what it needs of the whole session from HintFacts.History, worked out once per report; the median MCS comes from the counts per MCS.

Session Snapshot before after The scan before after Windows
10 min 20 ms 8 ms 17 ms 5 ms 118
20 min 53 ms 14 ms 48 ms 10 ms 238
30 min 97 ms 21 ms 88 ms 15 ms 358
40 min 168 ms 30 ms 154 ms 22 ms 478
52 min 249 ms 43 ms 237 ms 29 ms 627

The long-quiet stand-in; the snapshot after also holds the story and the Overview's time axis, which the screens made on the main thread before. The scan stays linear in the length of the session, some 45 µs per window. On the power-save-video stand-in, with Mist over its whole length, the snapshot takes 86 ms at 55 minutes, 74 ms of it the scan of 655 windows, some 110 µs each. The analysis of the frames took under a millisecond per second throughout, a copy of it held by the snapshot before included; BSSTable now changes a BSS's series in place instead of copying them with every beacon, which in these sessions, with three BSSes, cost little.

A queue of snapshots

The store made each second's snapshot in turn, and the session's other updates (frames per second, the stats lines, the next analysis) waited behind it; a snapshot slower than a second left a growing queue, each element a copy of the analysis. Now it makes one at a time, of the newest analysis: one a newer analysis replaced while it waited is never judged. In live() the footprint stayed between 200 and 300 MB over the 52 minutes.

The screens on the main thread

Per refresh near the end of the long-quiet stand-in (panes), before and after: the Overview 150 and 124 ms, Link metrics 302 and 108 ms, Capture 37 and 30 ms, the Timeline 17 and 15 ms. Link metrics drew a line mark and an invisible point mark per point; its lines are vectorized plots (LinePlot) now, its points drawn only where they show apart, and its renders are the same to the pixel. The time axis of the Overview draws its lanes as vectorized plots too, which also draw what a mark per point lost: two points of one second with one identifier, the old AP's and the new one's at a roam, left out part of the new AP's line.

The time axis was still most of the Overview's refresh. At 52 minutes its lanes hold 7,103 points on the long-quiet stand-in (the MCS 3,060, the AP's line 3,170, the loss per 5 s 935) and 7,714 on the power-save-video stand-in at 55 minutes. Alone, as wide as in the Overview of a window 1280 points wide (timeAxis, a plot of 976 points), drawing them all took 107 and 115 ms per refresh. With one lane at a time left out, by a switch made for the measurement, a dot of the MCS lane cost some 21 µs per redraw and a point of a line some 9 µs, the axis without its lanes 7 ms. Now it draws per point of the plot's width only what shows apart: of a line the lowest and the highest point and the ends of each segment, of the MCS one dot per value (PlotPoint.thinnedLine, thinnedDots); the readout reads every point. That is 4,230 and 4,915 points, 65 and 75 ms per refresh, and the Overview 69 instead of 123 ms. What it draws grows with the width, not with the session: per point of it two points of each line and a dot per value of the MCS, and the ends of each segment. The MCS lane's 1,622 dots are half of what is left. The renders of the recordings, shorter than their plot is wide, are the same to the pixel; those of the two stand-ins differ in 4,743 and 5,771 of 4.6 million pixels, all in the lanes and less than a point: the bands of the MCS end up to a point sooner, the AP's line turns within a point, and a light dot is drawn once where several of one value overlapped within a point and darkened each other.

The time axis also drew its lanes again for each move of the mouse over it: the moment hovered and the span being dragged were marks in every lane, so every lane's points went with them, 69 ms a move at 52 minutes and 65 ms dragging, measured with the marks put back in the lanes by a switch made for the measurement. Now they are a pointer of their own (TimeAxisPointer) that only the readout and the marks behind each lane read (PointerMarks): 3.2 and 0.9 ms a move, 5.7 and 2.4 ms on the power-save-video stand-in (timeAxis). The line hovered and the span keep their look; the line lies behind the gridlines now, and some 20 pixels differ where it crosses them. The renders without the pointer are the same to the pixel.

Link metrics draws its charts the same way, BA success, airtime and the AP's signal per point of their width only what shows apart: 75 instead of 109 ms per refresh at 52 minutes, 29 instead of 37 ms on the power-save-video stand-in. Thinning gains less there: a value per second has some three points per point of a chart about 1,000 points wide, and only in the busy stretches. With one section at a time left out, by a switch made for the measurement, the BA success chart took 29 ms, airtime 20 and the AP's signal 11. BA success had 1,848 points in 890 segments, its line broken at each interval with too little traffic, and a segment costs as a series: the 499 of one point drew nothing, and leaving them out of the line saved 10 ms with the render the same to the pixel. A line that crosses a gap, as airtime between bursts and the AP's signal between its periods on a band, keeps the points where it leaves and reaches the gap, so it crosses as it did. The thinning left the renders of the recordings the same to the pixel, and the Overview of the two stand-ins; their Link metrics differ in 8,998 and 8,075 of 4.6 million pixels, within the spikes of BA success and airtime and less than a point. A value alone between gaps is a dot now, on the charts of Link metrics and on the time axis (PlotPoint.lonePoints): the 499 dots of BA success cost 11 ms, Link metrics 86 and 31 ms per refresh, and the loss lane of synthetic-mlo, empty before as its one step per direction stood alone, shows a dot each.

On 2026-10-23 the airtime and the AP's signal in Link metrics break where an interval has no frames, as BA success did; before, their lines crossed every gap, the AP's signal on a band also across the time the client was on another. On testlab-power-save-video that is 101 segments of airtime and 51 of the AP's signal where there were two and one, and a segment costs as a series. Measured with a temporary test in a debug build, not the release build above: the three charts alone in a window 1280 points wide, with the series of testlab-power-save-video ten times in a row (55 minutes) and of testlab-long-quiet twice (65 minutes, its cut gaps included), ten refreshes each with the domain a second longer each time, the main thread's CPU time per refresh. Breaking the lines took them from 74 to 130 ms and from 21 to 32 ms. Now the segments of a direction or a link whose gap does not show at the chart's width are one series: the line's round caps, 2 points wide, close a gap of up to 2 points (PlotPoint.joined), and at 55 minutes one interval missing is 0.7 points. That makes 45 and 15 ms, below the 74 and 21 ms of the lines that crossed every gap, as BA success's own gaps join too: the lines as before with the join took 35 and 14 ms. The renders of the recordings change where a line now breaks; not compared to the pixel.

On 2026-10-25 the screens became panes with tabs, without the Details toggle that drew every pane's expert information at once. Per refresh near the end of testlab-long-quiet (32:40, the release build, panes), before with Details on and after per tab: the Overview 36 and 36 ms; the Timeline 19 ms, now its Story 11, Events 17, Handshakes 10; the Client 12, now 10 to 12; Surroundings 28, now Serving 11, Networks 21, Channels 13, AP MLDs 11; Link metrics 20, now Link's Delivery 13, Signal and rates 14, Power save 10; the Mist pane 12, now Link's Mist 10; Capture 37, now Radios 15, Tunes and filters 10, Session 13, Thresholds 10. A pane draws only its tab.

Live, the main thread did more than one refresh a second (live(), the Overview on screen): every change of the live state drew the window again, and with it the toolbar, which AppKit then built anew: the frames per second, a stats line per radio, the sensor's silence, the Mac's Wi-Fi every 2 s; and the latest lines of the story made a date formatter each. With the engine fixed and the screens as they were, the main thread took 26 % of the wall time at the start and 50 % at 47 minutes. Now the window's own body reads neither the live state nor the snapshot, the pipeline's reports change the live state once per tick, the Mac's Wi-Fi only when it changes, the Overview's time axis and tiles draw again only for another moment (SessionSnapshot.sameMoment), the story and the time axis are made with the snapshot, off the main thread, and the time formatters once: the main thread takes 5 % at the start and 12 % at 47 minutes, and with the time axis drawing only what shows apart 8 %. The process as a whole, with ten times live's frames, 45 and 59 %.

On 2026-11-04 Events on a long session (eventTable, the release build, a window 1280 points wide, the main thread per step). The longest recording, the hour of 2026-11-03 below, has 140 entries; for more its entries were repeated. HairlineTable was a LazyVStack, whose cost grows with the rows above what shows: far down a refresh took 77 ms at 1,000 rows and 1,354 ms at 20,000, so that at 1,000 rows it takes 8 % of the main thread at one refresh a second, and at 20,000 more than all of it. Now only the rows around what shows exist (RowWindow), and a row that did not change is not built again (HairlineRow):

Rows Refresh at the top Refresh far down Wheel far down Scroll bar, a jump of 2.4 %
140 9 and 15 ms 21 and 15 ms 3 and 3 ms 10 and 9 ms
1,000 11 and 14 ms 77 and 12 ms 12 and 8 ms 13 and 34 ms
5,000 14 and 15 ms 339 and 13 ms 26 and 8 ms 33 and 44 ms
20,000 27 and 20 ms 1,354 and 17 ms 89 and 8 ms 110 and 44 ms

Before and after in each cell. What is left no longer grows with the rows but for the filter's pass over the entries. A refresh at the top costs some 5 ms more on a short list, the rows beyond the window's edge, and a jump with the scroll bar 30 to 40 ms at 1,000 rows and more, every row drawn being a new one. On the table alone, from its last row at 20,000 rows, up took 13 ms and Page Up 36 ms; End landed on the last row exactly. Events as a whole, near the end of that hour (panes): 14 ms per refresh. A search typed adds the filter's pass to each refresh, which lowercases the text of every entry: at worst, with a search that matches nothing, 0.7 ms at 140 rows, 4 at 1,000, 18 at 5,000 and 74 at 20,000. That stays as it is: lowercasing once per entry moves the cost to each snapshot, for a list 140 times the longest recorded. The time of a row is formatted in its own body now, once per row that changes. A window out of sight in a test takes no clicks and no Tab, so the keys were measured on the table alone, without the Selected column beside it.

What this does not show: the sensor, the SSH streams and the four tshark processes, each with its own CPU and memory, which grow with the frames they decode.

An hour live on the sensor

On 2026-11-03 the app recorded 64 minutes live on sensor-1, the Release build of 5e4fe79 run from Xcode, with the MacBook as the client: eduroam on the AP45, ch 132 at 20 MHz; the reference client New Session kept from an earlier session, the MacBook's own address on testwlan-psk, sent nothing. 1,101,043 frames on capA and 76,992 on capB; on capA 17,200 a minute, 1.9 times as many as in the 51 minutes of 2026-10-21. The MacBook ran the app beside its own use, a video, browsing and idle stretches, its Wi-Fi off three times for the search, the last time with Stop. capB on Automatic, the second AP45's ch 108, changed for a quarter of an hour to ch 48 at 80 MHz (a U7, not of the site: the discovery filter there) and back. The Overview on screen for the first half hour, Link for the second. ps every minute, a sample of the app at the 50th minute.

Minutes Use Pane capA frames per minute The app's CPU, median and highest
1 to 10 video, browsing Overview 17,000 12 %, 27 %
11 to 20 browsing Overview 11,000 16 %, 33 %
21 to 32 idle Overview, Link from the 30th 4,000 15 %, 49 %
37 to 51 video Link 44,000 39 %, 108 %
52 to 63 browsing, idle Link 7,000 35 %, 76 %

The climb of 2026-10-21, 80 to 92 % from the 31st minute and 148 % in the 46th, did not come back with nearly twice the frames a minute. The memory: 242 MB before Start, 203 to 296 MB in the first 16 minutes, 299 to 339 MB to the 32nd, 327 to 386 MB in the second half, 336 MB after Stop.

The last ten minutes took 35 % at 7,000 frames a minute, against 15 % in the idle stretch at 4,000. The sample at the 50th minute, with Link, the video and 50,000 frames a minute: the main thread idle 53 % of the time and 42 % in SwiftUI's update of the views, three quarters of that in Swift Charts: 31 % of the time, some 310 ms of every second in Link's charts (4,724 samples on the main thread in 10 s, some 2.1 ms each). The app's own view code took under 2 %. Link was on screen only in the second half, so the hour does not tell Link's charts from the session's length.

Each capture switch starts a new pair of tshark processes for its radio, and with them its state per frame: capA's field pass went from 90 to 231 MB over the 25 minutes without a switch, some 740,000 frames, 0.19 MB per 1,000 frames; the JSON passes stayed at 90 MB.