<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:fh="http://purl.org/syndication/history/1.0"><channel><title>ship docs · ship blog</title><description/><link>https://shipmux.github.io/</link><language>en</language><fh:complete/><atom:link rel="self" href="https://shipmux.github.io/ship/blog/rss.xml"/><item><title>Ship 0.1.0</title><link>https://shipmux.github.io/ship/blog/ship-0-1-0/</link><guid isPermaLink="true">https://shipmux.github.io/ship/blog/ship-0-1-0/</guid><description>Ship 0.1.0 is out. It&apos;s a terminal multiplexer, like tmux or Zellij, with a sidebar that shows what your coding agents are doing, and your programs run on a...</description><pubDate>Thu, 12 Nov 2026 12:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Ship 0.1.0 is out. It&apos;s a terminal multiplexer, like tmux or Zellij, with a sidebar that shows what your coding agents are doing, and your programs run on a background server so closing the window doesn&apos;t stop them. Herdr is what got me started on it, and the goal is most of what I use Herdr for with a lot less code. The &lt;a href=&quot;https://github.com/shipmux/ship/releases/tag/v0.1.0&quot;&gt;release notes&lt;/a&gt; have the full list of what&apos;s in it.&lt;/p&gt;

&lt;p&gt;To install it on Linux or macOS, run this.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;curl &lt;/span&gt;&lt;span&gt;-fsSL&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;https://raw.githubusercontent.com/shipmux/ship/main/install.sh&lt;/span&gt;&lt;span&gt; | sh&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;ship&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h2 id=&quot;why-it-has-an-updater-on-day-one&quot;&gt;Why it has an updater on day one&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;ship update&lt;/code&gt; downloads the newest release, checks it against the published checksum and replaces the &lt;code dir=&quot;auto&quot;&gt;ship&lt;/code&gt; you ran, and the sidebar tells you when there&apos;s a new one. There&apos;s nothing newer than 0.1.0 yet, so today this is all it does.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;$&lt;/span&gt;&lt;span&gt; ship update --check&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;up to date&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The server and its windows talk over a protocol with a version number, and when a new &lt;code dir=&quot;auto&quot;&gt;ship&lt;/code&gt; can&apos;t talk to the server that&apos;s already running, the server has to restart, which ends the programs running in it. Your tabs, splits and directories come back with fresh shells, but whatever was running in them is gone. That number went from 1 on October 4 to 24 now, so 23 bumps in about five and a half weeks.&lt;/p&gt;
&lt;p&gt;The other way is capability negotiation, where the two sides work out which features they share and every old combination keeps working.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;it&apos;s why i settled on the protocol version thing. it&apos;s like a minute of inconvinience for the end user at most every few weeks or months, at the cost of having one hell of a lot less bugs, simpler code, faster features, easier maintenance, and of course, everything is exponentially better than the opposite because debt tends to compound, especially with agents writing the code.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The case for keeping programs alive is mostly about agents, and I don&apos;t think it holds up.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;agent conversations are like... the MOST resumable, pausable thing on the planet? they&apos;re literally built for &quot;durable&quot; execution, replayability, whatever? right? so like the only thing that even makes sense is like the jobs the agents run, and even those are usually either short lived or long lived but deterministic? like compilation?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;On November 6 I went through the ways to do it anyway. One was a long-lived holder process that owns the programs, so the server can restart around them.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;the holder one sounds like platform specific nightmare fuel...&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;So I asked for the cleanest way that wasn&apos;t that, and about ten minutes later it turned out to be the same thing.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;wait i just realized something. this is basically the holder wearing a different coat of paint. right? because like... you still need the whole api thing? and the entire point of the upgrade and protocol is that we can break crap more freely and we&apos;re not afraid to do that instead of maintaining infinite layers of cruft and baggage&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;So restarting is the one way back, whether it&apos;s an update, a crash or a reboot.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;same reason everyone tells you to just restart your pc after updating stuff like your kernel, it&apos;s just not worth it.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;When the new &lt;code dir=&quot;auto&quot;&gt;ship&lt;/code&gt; can&apos;t talk to the old server, it says so and prints the command to restart it. &lt;code dir=&quot;auto&quot;&gt;ship update&lt;/code&gt; never stops the server on its own, so you pick when.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;settling-the-json-before-people-script-against-it&quot;&gt;Settling the JSON before people script against it&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Right before the release I went through everything a script would read and settled it in &lt;a href=&quot;https://github.com/shipmux/ship/pull/126&quot;&gt;#126&lt;/a&gt;. Some of it was renames I didn&apos;t want to be stuck with. A pane&apos;s &lt;code dir=&quot;auto&quot;&gt;status&lt;/code&gt; was really about its process, so it&apos;s &lt;code dir=&quot;auto&quot;&gt;process&lt;/code&gt; now, and every route dropped the &lt;code dir=&quot;auto&quot;&gt;v0&lt;/code&gt; from &lt;code dir=&quot;auto&quot;&gt;/api/v0/&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Before that PR, if a newer server sent a kind of event an older window didn&apos;t know, the window couldn&apos;t decode it and broke, so even adding something needed a protocol bump and a restart. Now the window checks the event&apos;s &lt;code dir=&quot;auto&quot;&gt;type&lt;/code&gt; first and skips the ones it has never heard of. This is the part of &lt;a href=&quot;https://github.com/shipmux/ship/blob/main/crates/ship-client/src/api.rs&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;api.rs&lt;/code&gt;&lt;/a&gt; that does it.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;match&lt;/span&gt;&lt;span&gt; serde_json::&lt;/span&gt;&lt;span&gt;from_str&lt;/span&gt;&lt;span&gt;::&amp;#x3C;&lt;/span&gt;&lt;span&gt;Kind&lt;/span&gt;&lt;span&gt;&gt;(data) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;Ok&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;Kind&lt;/span&gt;&lt;span&gt; { kind }) &lt;/span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;SseKind&lt;/span&gt;&lt;span&gt;::&lt;/span&gt;&lt;span&gt;from_str&lt;/span&gt;&lt;span&gt;(kind).&lt;/span&gt;&lt;span&gt;is_err&lt;/span&gt;&lt;span&gt;() =&gt; &lt;/span&gt;&lt;span&gt;Ok&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;None&lt;/span&gt;&lt;span&gt;),&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;_ =&gt; &lt;/span&gt;&lt;span&gt;Err&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;err!&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;Serialization&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;cannot decode attach event&quot;&lt;/span&gt;&lt;span&gt;, @external: error)),&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;An event type the window does know but can&apos;t decode still ends it with an error, since that means a bug or a bump I forgot, and reconnecting would just get the same bytes again. All the code that exists only for compatibility is about 35 lines, and the rule for the project is that it stays in the tens.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;i think i like the cheap one. 80/20 rule. try to be nice for the easy 80%, but if stuff has to break, or if it&apos;s not worth compat, then just break it and move on&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I got one of these wrong first. I picked having the server refuse request bodies with fields it didn&apos;t know, mostly because it was more consistent, and a typo would fail loudly. Before it was built I came back to it.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;that&apos;s the one thing i was worried about. is like, in the cases where programs want to act like dumb pipes, sometimes they can&apos;t and are instead forced to parse certain fields in or out, you get what i mean?... does that feel... unergonomic? i feel like it is?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It is. The most obvious thing a script does is get a pane, change a field and send it back, and what you get has fields the update doesn&apos;t take.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;i feel like keeping it dumb is what most http endpoints do for the most part too, right? like just in general? it&apos;s friendlier?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;So request bodies stay lenient, and typos get caught by the CLI and the config file, which is where people actually type.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;ship-watch-and-plugins&quot;&gt;ship watch and plugins&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Plugins were going to get their own set of events, which is a second API to keep in line with the first one, when Ship&apos;s own window already gets everything from one stream.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;could we literally not just offer that as a cli command that streams events json lines to stdout? the same way the client would normally get them from the server?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;So that&apos;s what &lt;code dir=&quot;auto&quot;&gt;ship watch&lt;/code&gt; is. It prints the stream the window gets, one JSON line per change, starting with the same line &lt;code dir=&quot;auto&quot;&gt;ship server status&lt;/code&gt; prints.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;$&lt;/span&gt;&lt;span&gt; ship watch&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&quot;service&quot;:&quot;ship&quot;,&quot;protocol_version&quot;:24,&quot;version&quot;:&quot;0.1.0&quot;,…}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&quot;type&quot;:&quot;attached&quot;,&quot;data&quot;:{&quot;attachment&quot;:&quot;attachment:79b0…&quot;,&quot;replica&quot;:{…}}}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;It passes every line through without reading it, so if the window&apos;s stream changes, so does the plugin&apos;s, with nothing in between to keep in sync. A plugin is a script that reads it and puts short text on panes with &lt;code dir=&quot;auto&quot;&gt;--tag&lt;/code&gt;. There&apos;s no runtime and no install step. The first thing I wanted from one was something Herdr already does.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;i&apos;m pretty sure that herdr renders certain visuals, like the branch name or worktree management for example. could we support that?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Here&apos;s the heart of the &lt;a href=&quot;https://github.com/shipmux/ship/blob/main/examples/plugins/git.sh&quot;&gt;git example&lt;/a&gt;, which shows each tab&apos;s branch and pull request under its name.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&quot;$ship&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;watch&lt;/span&gt;&lt;span&gt; | {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;read &lt;/span&gt;&lt;span&gt;-r&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;health&lt;/span&gt;&lt;span&gt; || exit &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;# ...&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;jq &lt;/span&gt;&lt;span&gt;--unbuffered&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;-r&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;(.data.replica // select(.type == \&quot;state\&quot;).data) | $panes&quot;&lt;/span&gt;&lt;span&gt; | sync&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;sync&lt;/code&gt; asks git for each pane&apos;s branch and runs &lt;code dir=&quot;auto&quot;&gt;ship pane set --pane &quot;$1&quot; --tag &quot;$2=$4&quot; --tag-fg &quot;$2=$5&quot;&lt;/code&gt; when it changed. The color comes from the plugin too, not from Ship&apos;s config.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;having the plugin itself set it feels smarter to me? that way if it has custom logic for how to color it, the user can just edit the plugin logic or whatever?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The whole thing is 135 lines of shell, and it reads &lt;code dir=&quot;auto&quot;&gt;protocol_version&lt;/code&gt; off that first line and warns you when it was written for a different one. &lt;a href=&quot;https://shipmux.github.io/ship/docs/scripting/writing-a-plugin/&quot;&gt;Writing a plugin&lt;/a&gt; walks through it.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;whats-not-here&quot;&gt;What&apos;s not here&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The README has a list of what &lt;a href=&quot;https://github.com/shipmux/ship#not-built-yet&quot;&gt;isn&apos;t built yet&lt;/a&gt;. If something breaks on your machine, open an issue, and when the fix comes out, &lt;code dir=&quot;auto&quot;&gt;ship update&lt;/code&gt; will get it to you.&lt;/p&gt;
</content:encoded></item><item><title>Optimal-ish</title><link>https://shipmux.github.io/ship/blog/optimal-ish/</link><guid isPermaLink="true">https://shipmux.github.io/ship/blog/optimal-ish/</guid><description>I was pretty sure Ship was fast. It sends your window the whole screen every time anything changes, which sounds wasteful, but it had beaten every diff I...</description><pubDate>Mon, 26 Oct 2026 12:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I was pretty sure Ship was fast. It sends your window the whole screen every time anything changes, which sounds wasteful, but it had beaten every diff I tried, and I remembered it leading on latency in every test I&apos;d run. Then I had my agent build a proper benchmark against Herdr and Zellij, and a key you typed took more than five times longer to show up in Ship than in Herdr. I use Herdr every day and the whole point of Ship is to stop needing it, so this was like opening a PR to replace something and having the benchmark say the old one is faster.&lt;/p&gt;

&lt;p&gt;This post is the couple of weeks after that, in the order it happened, and the quotes are what I said to my agent while we were in the middle of it. The first four sections cover how Ship gets a screen from its server to your window, and the race starts in section 5.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;1-two-programs-not-one&quot;&gt;1. Two programs, not one&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Ship runs as two pieces. There&apos;s a server in the background that owns your terminals, and there&apos;s the window you see when you run &lt;code dir=&quot;auto&quot;&gt;ship&lt;/code&gt;, and you can close the window or open three of them and the work underneath keeps going.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;shell, nvim, claude ──▶ server ──screens──▶ window ──▶ your terminal&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;                        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;(runs them)          (draws them)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The window never runs your programs itself. The server reads everything they print and feeds it into a terminal emulator, which is the part of a terminal app that understands colors and cursor movement and all the escape codes programs use to draw. Ship uses Ghostty&apos;s. What comes out is a screen, a grid of characters with colors, and the window&apos;s job is to get that screen and draw it, over and over, fast enough that typing feels instant and a pane full of logs doesn&apos;t drag everything else down with it.&lt;/p&gt;
&lt;div role=&quot;note&quot;&gt;&lt;span aria-hidden=&quot;true&quot;&gt;&lt;/span&gt;&lt;div&gt;&lt;p&gt;This split is also why closing the window doesn&apos;t stop anything. &lt;a href=&quot;https://shipmux.github.io/ship/docs/windows-and-the-server/&quot;&gt;Windows and the server&lt;/a&gt; has the rest.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div&gt;&lt;h2 id=&quot;2-diffs-that-kept-losing&quot;&gt;2. Diffs that kept losing&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The obvious way to get a screen to the window is to send only what changed, so if you type one letter you send one cell. That&apos;s where I started, in an experiment before I&apos;d written any of Ship, and it falls apart as soon as something scrolls, because when a new line shows up at the bottom every other line moves up by one.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;before                           after one new line&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;12:04:01 info  GET /items/7      12:04:02 info  GET /items/14&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;12:04:02 info  GET /items/14     12:04:03 info  GET /items/21&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;12:04:03 info  GET /items/21     12:04:04 info  GET /items/28&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;12:04:04 info  GET /items/28     12:04:05 info  GET /items/35&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Compare each row with the one next to it and none of them match, so a simple diff ends up sending the whole screen anyway and does extra work to get there. I tried JSON Patch first, and on a one-row scroll the patch came out bigger than the screen itself, about 46 KB of patch against 9.5 KB for the compressed screen, and it took 15 to 27 ms per frame to compute.&lt;/p&gt;
&lt;p&gt;A smarter diff can notice that the lines just moved, and then it only sends the new one, so I tried that too. The messages got small, but figuring out what moved where took about half a second on a big screen, even when nothing had changed, and you can&apos;t wait half a second to see your own typing.&lt;/p&gt;
&lt;table tabindex=&quot;0&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;a 160×50 screen&lt;/th&gt;
&lt;th&gt;size on the wire&lt;/th&gt;
&lt;th&gt;time to make it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;whole screen, compressed&lt;/td&gt;
&lt;td&gt;7.7 to 10.5 KB&lt;/td&gt;
&lt;td&gt;barely anything&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JSON Patch, one scroll&lt;/td&gt;
&lt;td&gt;46 KB&lt;/td&gt;
&lt;td&gt;15 to 27 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;diff that tracks moves&lt;/td&gt;
&lt;td&gt;tiny&lt;/td&gt;
&lt;td&gt;about 520 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The whole screen with compression on top was smaller than one diff and faster than the other. The diffs never saved enough to be worth it, so I went with the whole screen because it was the simplest thing and I just wanted a prototype out, and the lazy option and the right option happened to be the same one, which almost never happens.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;3-the-compressor-that-remembers&quot;&gt;3. The compressor that remembers&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Terminal screens repeat themselves a lot. Most of a screen is blank, most of the text is in one or two colors, and a log is the same shape of line over and over with different numbers in it. That&apos;s what compression is good at, the same thing a zip file does when it finds the repeated parts and writes them down once, and Ship&apos;s server already talks to the window over HTTP, which can compress everything it sends without Ship doing anything special. It&apos;s one line in the server.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;CompressionLayer&lt;/span&gt;&lt;span&gt;::&lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt;().&lt;/span&gt;&lt;span&gt;zstd&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;).&lt;/span&gt;&lt;span&gt;gzip&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;That&apos;s not just compressing each screen on its own, though, and this is the part I didn&apos;t get at first. The window opens one long stream to the server and every screen goes down that same stream, so there&apos;s one zstd compressor per window, and it keeps a window of everything it sent recently. When the next screen shows up, zstd finds that almost all of it matches the screen it just sent and writes &quot;same as before&quot; instead of the screen.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;stream to your window&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;screen 1   ████████████████████████  compressed on its own&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;screen 2   ████████████████████████  &quot;like screen 1, but row 30 has one more letter&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;                                        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;sent as ~27 bytes&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;So when a coding agent&apos;s spinner ticks and nothing else moves, the server sends a whole screen and about 27 bytes go over the wire, which is about the size a diff would be anyway. zstd ends up doing the diffing, and Ship never has to keep track of what the window already has. There&apos;s a comment in Ship&apos;s screen type that says so.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;/// Full snapshot; zstd on the stream supplies the savings between frames.&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;So zstd had been doing the diffing for me the whole time, which is a bit like finding out a dependency you already have does the thing you were about to write.&lt;/p&gt;
&lt;p&gt;That memory isn&apos;t free. Each attached window costs about 3.7 MB of zstd state on the server and 2.5 MB in the window, and I think that&apos;s a fine trade for never having a diff go out of sync.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;rows-instead-of-cells&quot;&gt;Rows instead of cells&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;How the screen gets written down still mattered. Ship used to describe every cell on the screen as its own little object, blank ones included, and it changed to sending each row as its text plus a short list of which stretches have which style.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;pub&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;struct&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Screen&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;pub&lt;/span&gt;&lt;span&gt; size: &lt;/span&gt;&lt;span&gt;Size&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;/// `size.cols * size.rows` cells.&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;pub&lt;/span&gt;&lt;span&gt; cells: &lt;/span&gt;&lt;span&gt;Vec&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Cell&lt;/span&gt;&lt;span&gt;&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;pub&lt;/span&gt;&lt;span&gt; rows: &lt;/span&gt;&lt;span&gt;Vec&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Row&lt;/span&gt;&lt;span&gt;&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;pub&lt;/span&gt;&lt;span&gt; cursor: &lt;/span&gt;&lt;span&gt;Option&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Cursor&lt;/span&gt;&lt;span&gt;&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;// ...&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Here&apos;s the first line of a small log screen in both shapes, captured from a running server.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&quot;cells&quot;&lt;/span&gt;&lt;span&gt;: [&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;&quot;symbol&quot;&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt;&quot;1&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt;&quot;attrs&quot;&lt;/span&gt;&lt;span&gt;:[&lt;/span&gt;&lt;span&gt;&quot;dim&quot;&lt;/span&gt;&lt;span&gt;]}, {&lt;/span&gt;&lt;span&gt;&quot;symbol&quot;&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt;&quot;2&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt;&quot;attrs&quot;&lt;/span&gt;&lt;span&gt;:[&lt;/span&gt;&lt;span&gt;&quot;dim&quot;&lt;/span&gt;&lt;span&gt;]},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;&quot;symbol&quot;&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt;&quot;:&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt;&quot;attrs&quot;&lt;/span&gt;&lt;span&gt;:[&lt;/span&gt;&lt;span&gt;&quot;dim&quot;&lt;/span&gt;&lt;span&gt;]}, {&lt;/span&gt;&lt;span&gt;&quot;symbol&quot;&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt;&quot;0&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt;&quot;attrs&quot;&lt;/span&gt;&lt;span&gt;:[&lt;/span&gt;&lt;span&gt;&quot;dim&quot;&lt;/span&gt;&lt;span&gt;]},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;... one of these for every cell, and {} for every blank one&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&quot;rows&quot;&lt;/span&gt;&lt;span&gt;: [&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;&quot;text&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;12:04:01 info  GET /api/items/7  200  7ms&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;   &lt;/span&gt;&lt;span&gt;&quot;runs&quot;&lt;/span&gt;&lt;span&gt;: [{&lt;/span&gt;&lt;span&gt;&quot;cells&quot;&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt;8&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt;&quot;attrs&quot;&lt;/span&gt;&lt;span&gt;:[&lt;/span&gt;&lt;span&gt;&quot;dim&quot;&lt;/span&gt;&lt;span&gt;]}, {&lt;/span&gt;&lt;span&gt;&quot;cells&quot;&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;            &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;&quot;cells&quot;&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt;4&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt;&quot;fg&quot;&lt;/span&gt;&lt;span&gt;:{&lt;/span&gt;&lt;span&gt;&quot;indexed&quot;&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt;2&lt;/span&gt;&lt;span&gt;}}, {&lt;/span&gt;&lt;span&gt;&quot;cells&quot;&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt;20&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;            &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;&quot;cells&quot;&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt;3&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt;&quot;attrs&quot;&lt;/span&gt;&lt;span&gt;:[&lt;/span&gt;&lt;span&gt;&quot;bold&quot;&lt;/span&gt;&lt;span&gt;]}]},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;table tabindex=&quot;0&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;that screen&lt;/th&gt;
&lt;th&gt;as sent&lt;/th&gt;
&lt;th&gt;compressed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;one object per cell&lt;/td&gt;
&lt;td&gt;10,578 bytes&lt;/td&gt;
&lt;td&gt;387 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;rows of text&lt;/td&gt;
&lt;td&gt;1,385 bytes&lt;/td&gt;
&lt;td&gt;294 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Compression already did most of the work even on the clunky format, since 1,920 nearly identical little objects are about as repetitive as text gets. The difference showed up when I timed both ends.&lt;/p&gt;
&lt;table tabindex=&quot;0&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;per screen, 103×47&lt;/th&gt;
&lt;th&gt;server writes it&lt;/th&gt;
&lt;th&gt;window reads and draws it&lt;/th&gt;
&lt;th&gt;on the wire&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;agent, cells&lt;/td&gt;
&lt;td&gt;0.129 ms&lt;/td&gt;
&lt;td&gt;0.215 ms&lt;/td&gt;
&lt;td&gt;80 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;agent, rows&lt;/td&gt;
&lt;td&gt;0.015 ms&lt;/td&gt;
&lt;td&gt;0.023 ms&lt;/td&gt;
&lt;td&gt;27 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;git log, cells&lt;/td&gt;
&lt;td&gt;0.134 ms&lt;/td&gt;
&lt;td&gt;0.218 ms&lt;/td&gt;
&lt;td&gt;1,695 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;git log, rows&lt;/td&gt;
&lt;td&gt;0.014 ms&lt;/td&gt;
&lt;td&gt;0.021 ms&lt;/td&gt;
&lt;td&gt;75 B&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The bytes on the wire depend on how much changed, but the time to write and read a screen depends on how big it is before compression, and rows made that about 9 times smaller. Compression makes a verbose format almost free on the wire, but it does nothing about the work on either end.&lt;/p&gt;
&lt;p&gt;I also tried rows written as plain terminal escape codes, the same thing a program prints, since that&apos;s even smaller. It broke wide characters like CJK text and some emoji, which take up two cells, and 1,498 out of 1,499 screens in that test came out wrong. Rows with named fields won, partly because they&apos;re easy to add to later without breaking older windows.&lt;/p&gt;
&lt;div role=&quot;note&quot;&gt;&lt;span aria-hidden=&quot;true&quot;&gt;&lt;/span&gt;&lt;div&gt;&lt;p&gt;A nice side effect of rewriting this was finally finding out why Neovim&apos;s error squiggles never showed up when I used Zellij about a year ago. Zellij drops the colored underlines, and so did Ship. Rows keep the underline color now. The squiggly style itself is still waiting on &lt;a href=&quot;https://github.com/ratatui/ratatui/issues/2734&quot;&gt;ratatui #2734&lt;/a&gt;.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div&gt;&lt;h2 id=&quot;4-only-the-newest-screen&quot;&gt;4. Only the newest screen&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The other half is that your window never needs every screen, just the newest one. A pane dumping a big log can change thousands of times in a fraction of a second, and if Ship sent all of those, the window would fall further and further behind and your keystrokes would be waiting in line behind all of it.&lt;/p&gt;
&lt;p&gt;So the server keeps one slot per pane, and each new screen just replaces whatever was in it. In the code that&apos;s a single line.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;self&lt;/span&gt;&lt;span&gt;.screen.&lt;/span&gt;&lt;span&gt;send_replace&lt;/span&gt;&lt;span&gt;(Arc::&lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt;(screen));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;On the other side, the stream to your window waits for the slot to change and takes whatever is there right then.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;receiver.&lt;/span&gt;&lt;span&gt;changed&lt;/span&gt;&lt;span&gt;().&lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt;?;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;Ok&lt;/span&gt;&lt;span&gt;(receiver.&lt;/span&gt;&lt;span&gt;borrow_and_update&lt;/span&gt;&lt;span&gt;().&lt;/span&gt;&lt;span&gt;clone&lt;/span&gt;&lt;span&gt;())&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;If ten screens went by while the window was busy, it never sees the nine in the middle, and it doesn&apos;t need to, because nobody can read a log scrolling that fast anyway. There&apos;s never a backlog to work through.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;program prints  ████████████████████████████████████████&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;screens made    1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 ...&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;window takes    1         5            10             16&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;It also keeps the rest of Ship simple.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;oh and that doesn&apos;t even begin to count the fact that it makes the architecture much easier to reason about since everyone just gets the latest state at all times&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;In the benchmark, I printed 16 MiB of logs into a pane, and about 15 KB went from the server to the window. With all that in place I figured Ship was done being slow, and the next step was to race it against the others.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;5-the-first-race&quot;&gt;5. The first race&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;I had my agent write a benchmark that runs Ship, Herdr and Zellij side by side in a terminal and measures things like CPU, memory, how fast a log flood gets through and how long a keystroke takes to show up. Getting it to measure the right thing took a few tries, because the harness kept tripping over something.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Zellij ignores &lt;code dir=&quot;auto&quot;&gt;XDG_CONFIG_HOME&lt;/code&gt; on macOS, so it kept opening its first-run setup screen, which quietly ate every key the benchmark typed.&lt;/li&gt;
&lt;li&gt;Herdr moves the cursor around after non-ASCII characters, so the &lt;code dir=&quot;auto&quot;&gt;⟦END⟧&lt;/code&gt; marker the benchmark waited for never showed up as one piece, and every run timed out.&lt;/li&gt;
&lt;li&gt;The benchmark typed 16 different keys in a cycle, and the 16th one happened to be the quit key for the little echo program on the other end.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Once it was measuring the right thing, the first numbers came back, medians of three runs.&lt;/p&gt;
&lt;table tabindex=&quot;0&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;measure&lt;/th&gt;
&lt;th&gt;Ship&lt;/th&gt;
&lt;th&gt;Herdr&lt;/th&gt;
&lt;th&gt;Zellij&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;log flood&lt;/td&gt;
&lt;td&gt;72 ms&lt;/td&gt;
&lt;td&gt;109 ms&lt;/td&gt;
&lt;td&gt;265 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;agent CPU&lt;/td&gt;
&lt;td&gt;2.2%&lt;/td&gt;
&lt;td&gt;2.3%&lt;/td&gt;
&lt;td&gt;3.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;memory&lt;/td&gt;
&lt;td&gt;45 MB&lt;/td&gt;
&lt;td&gt;41 MB&lt;/td&gt;
&lt;td&gt;100 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;typing (idle)&lt;/td&gt;
&lt;td&gt;3.93 ms&lt;/td&gt;
&lt;td&gt;0.74 ms&lt;/td&gt;
&lt;td&gt;13.35 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;
&lt;p&gt;not surprised that we win on flood, although i&apos;m a bit confused... on the typing one... are we winning or losing?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;We were losing, and by a lot. Floods were great, and while an agent was printing Zellij wrote 5.2 MB to the terminal against 64 KB for Ship, about 80 times more, because Zellij forwards a lot more of every change, but typing in a quiet pane took 3.93 ms in Ship and 0.74 ms in Herdr, more than five times slower, and that&apos;s the number you feel every time you press a key.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;interesting... that&apos;s very strange. i remember leading on latency in pretty much every test i did. what are you thinking the culprit is?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;div&gt;&lt;h2 id=&quot;6-a-millisecond-that-wasnt&quot;&gt;6. A millisecond that wasn&apos;t&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Ship had a small wait before sending a screen, 1 ms, so a program that draws twice in a row (Neovim does this when it scrolls) comes out as one screen instead of showing you the half-finished one. One millisecond shouldn&apos;t add up to three, so my agent dug into where the time went.&lt;/p&gt;
&lt;p&gt;Ship runs on tokio, and tokio&apos;s timers don&apos;t wake you at the exact time you ask. They tick in 1 ms slots and round up to the next one, so asking for 1 ms can easily mean 2.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;time (ms)          10        11        12&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;tokio&apos;s ticks       |---------|---------|&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;screen changes      ▲ 10.3&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;asks to wake at               ▲ 11.3&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;actually wakes                          ▲ 12.0&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Even asking to wake at &lt;code dir=&quot;auto&quot;&gt;Instant::now()&lt;/code&gt;, which should mean right now, waited for the next tick, about 1.3 ms on every echo. So the wait was costing more than its own length on every keypress.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;i find that rather odd/strange. why does it wait for ms rounding? is there no way around that?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Herdr has a rule for this, and it&apos;s a good one. Normally it draws a pane at most once every 16 ms, but a keypress opens a short window where that pane draws right away, so your echo never waits behind any timer. Ship borrowed the idea.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;a pane&apos;s screen changed&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;if you pressed a key in the last 32 ms&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;send it now (at most 16 of these per 16 ms, so a flood still paces)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;otherwise&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;wait 1 ms, so a program that draws twice lands as one screen&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;and keep at least 4 ms since the last one sent&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;That 4 ms came from an earlier wait that bit me. The first day Ship&apos;s window actually worked, holding &lt;code dir=&quot;auto&quot;&gt;j&lt;/code&gt; in lf stuttered so badly I blamed the debug build, like you do, and it turned out the pane stopped passing keys to the program while it waited out a 16 ms frame.&lt;/p&gt;
&lt;table tabindex=&quot;0&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;version&lt;/th&gt;
&lt;th&gt;typing (idle)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;rows&lt;/td&gt;
&lt;td&gt;3.90 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;keypress rule, still with timers&lt;/td&gt;
&lt;td&gt;2.77 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sending inline, no timer at all&lt;/td&gt;
&lt;td&gt;1.93 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Herdr&lt;/td&gt;
&lt;td&gt;0.79 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Sending inline worked, but in that first version it was a publish dropped into the middle of the pane&apos;s loop, and when it came time to merge it I didn&apos;t really like it.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;i&apos;m mostly happy with that, i just think it reads a bit dirty to just publish straight in there. am i the only one who&apos;s bothered/annoyed by that?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;So the version I merged keeps all of it in a small &lt;code dir=&quot;auto&quot;&gt;Pacing&lt;/code&gt; struct, and the pane asks it whether a screen is due before it waits on anything else.&lt;/p&gt;
&lt;p&gt;That got a lot closer, but there was still a millisecond missing, so my agent timed each hop of the trip.&lt;/p&gt;
&lt;table tabindex=&quot;0&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;hop&lt;/th&gt;
&lt;th&gt;ms&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;key reaches the server&lt;/td&gt;
&lt;td&gt;0.36&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;server starts publishing&lt;/td&gt;
&lt;td&gt;0.22&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;capturing the screen&lt;/td&gt;
&lt;td&gt;0.21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JSON and sending it&lt;/td&gt;
&lt;td&gt;0.09&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;screen reaches the window&lt;/td&gt;
&lt;td&gt;0.21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;window redraws&lt;/td&gt;
&lt;td&gt;0.47&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Nothing in there was huge on its own, so I left it for later.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;7-the-flood-that-drew-too-much&quot;&gt;7. The flood that drew too much&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Ship was winning floods on time, but when I looked at CPU per megabyte, Ship used about 20% more than Herdr to chew through the same output. Both use Ghostty to understand the output, so the parsing should cost the same, and the difference had to be somewhere in Ship&apos;s own code.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;for the flood case... what do you think is going on there? that feels something that could heavily benefit from profiling, no?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Getting a profile took some work. samply needed a release build with symbols and link-time optimization left on, plus a debugging permission macOS wants before it lets you look inside a process, and the first profile claimed Ship had allocated 40 GB in 9 seconds, which was the profiler being confused and not Ship. The second one made sense.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;copying Ghostty&apos;s screen out    ███████████████████████▌  47%   (27% of it malloc)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;reading from the terminal       █████████████████         34%&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;Ghostty parsing                 ███████▌                  15%&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Almost half the time went to copying Ghostty&apos;s screen into a form Ship can draw, and it was doing that after every read from the program, while the server only publishes a screen every 4 ms. So most of those copies were thrown away without anyone looking at them, and during a flood that was about 1.4 CPU cores, including for panes you can&apos;t see.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;reads      r r r r r r r r r r r r r r r r r r r r r r r r&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;copies     C C C C C C C C C C C C C C C C C C C C C C C C&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;published          ▲               ▲               ▲        (every 4 ms)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;blockquote&gt;
&lt;p&gt;wait WTF? how much more do you think we&apos;re paying for all of that? ... i had no idea dang. i wonder if that is impacting a bunch of other things&lt;/p&gt;
&lt;/blockquote&gt;
&lt;div&gt;&lt;h3 id=&quot;two-fixes-that-did-nothing&quot;&gt;Two fixes that did nothing&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;The first idea was to copy once per batch of reads instead of after every read. It did nothing, because the queue of reads was nearly always empty after one chunk, so a batch was almost always one read.&lt;/p&gt;
&lt;p&gt;The second idea was to make the copy cheaper. My agent got rid of the allocation for every cell, and malloc dropped from 27% to about 0% of the profile, but CPU per megabyte went from 11.2 ms to 10.9 ms, which is basically nothing. The thread was flat out the whole time, so a cheaper copy just meant it got through each one faster and then did more of them.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;isn&apos;t the right way to only render the frame when asked to by the publishing task?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;So that&apos;s what Ship does now, the copy only happens when the server is about to publish and asks for it, and copying went from 47% of the profile to 0.2%. But CPU per megabyte only went from 10.7 ms to 10.4 ms, because waking and sleeping the session thread had jumped to 31% of the new profile.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;session thread, one flood:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;[c]zz[c]zz[c]zz[c]zz[c]zz[c]zz[c]zz[c]zz[c]zz[c]zz ...&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;   &lt;/span&gt;&lt;/span&gt;&lt;span&gt;c = take a chunk of output, zz = go back to sleep, wake up again&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;My agent worked out it had to be waking up at least 34,000 times a second per pane, doing a bit of work and going back to sleep. Somewhere around here I noticed Ship was reading from the terminal 4 KB at a time and lost it a bit.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;wtf is the 4KB limit? i feel like i don&apos;t have enough context to understand anything...&lt;/p&gt;
&lt;p&gt;and why the hell would we pick such a small bucket? what&apos;s the benefit of that? would 128KB not be much smarter?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That 34,000 assumed every read got the full 4 KB, so my agent traced a flood to see how big the reads were, and they were a lot smaller than that.&lt;/p&gt;
&lt;table tabindex=&quot;0&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;per pane, per second, during a flood&lt;/th&gt;
&lt;th&gt;count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;reads from the terminal&lt;/td&gt;
&lt;td&gt;122,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;average bytes per read&lt;/td&gt;
&lt;td&gt;1,018&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;buffers allocated and sent&lt;/td&gt;
&lt;td&gt;122,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;session thread wake-ups&lt;/td&gt;
&lt;td&gt;91,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;screens actually published&lt;/td&gt;
&lt;td&gt;~165&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;macOS hands back at most about 1 KB from a terminal per read, no matter how big a buffer you give it, so 128 KB wouldn&apos;t have changed anything.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;wait what? so we have to wake every 1KB? or am i reading something very wrong? WTF? that seems so cursed? i have to be reading it wrong, right?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I wasn&apos;t reading it wrong, but the fix didn&apos;t need bigger reads, it needed fewer wake-ups. The reading thread now appends whatever it read to one shared buffer, and it only wakes the session thread when that buffer was empty, because if there were already bytes in it, the session thread is already on its way.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;let&lt;/span&gt;&lt;span&gt; was_empty = queue.bytes.&lt;/span&gt;&lt;span&gt;is_empty&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;queue.bytes.&lt;/span&gt;&lt;span&gt;extend_from_slice&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&amp;#x26;&lt;/span&gt;&lt;span&gt;buf[..n]);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; was_empty || queue.bytes.&lt;/span&gt;&lt;span&gt;len&lt;/span&gt;&lt;span&gt;() &gt;= PENDING_CAP {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;pty_tx.&lt;/span&gt;&lt;span&gt;try_send&lt;/span&gt;&lt;span&gt;(()).&lt;/span&gt;&lt;span&gt;ok&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The session thread takes everything that piled up at the top of each loop, all at once. There&apos;s a 1 MiB cap, so a program that floods faster than Ghostty can keep up just has to wait, instead of eating all your memory. My agent&apos;s first version had every command remember to catch up on output before running.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;is it just me or does that feel a bit hacky to have to remember? is there a cleaner way?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;So there&apos;s a single catch-up at the top of the loop instead, one place that does it and nothing to forget.&lt;/p&gt;
&lt;table tabindex=&quot;0&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;server CPU per MB of output&lt;/th&gt;
&lt;th&gt;ms&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;before&lt;/td&gt;
&lt;td&gt;10.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;copy only when asked&lt;/td&gt;
&lt;td&gt;10.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;copy when asked + batching&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6.1&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Herdr&lt;/td&gt;
&lt;td&gt;9.1 to 10.5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;
&lt;p&gt;so this time it actually improved and the gains didn&apos;t just get eaten by some other crap? or no?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It did this time. Wake-ups went from about 91,000 a second to about 165, the same as the number of screens published, which is what it should be. What&apos;s left is Ghostty parsing at about 2 ms per MB and the reads themselves at about 4 ms per MB, roughly 4 µs per syscall, and there&apos;s nothing Ship can do about either one. I also tried building Ghostty for the exact CPU in my laptop, and that didn&apos;t change a thing.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;8-faster-when-busy&quot;&gt;8. Faster when busy&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;With the flood fixed, my agent ran a quick check on typing.&lt;/p&gt;
&lt;table tabindex=&quot;0&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;typing p50&lt;/th&gt;
&lt;th&gt;before&lt;/th&gt;
&lt;th&gt;after keypress rule&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;beside floods&lt;/td&gt;
&lt;td&gt;2.85 ms&lt;/td&gt;
&lt;td&gt;0.49 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;in a quiet pane&lt;/td&gt;
&lt;td&gt;4.05 ms&lt;/td&gt;
&lt;td&gt;1.80 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Typing in a pane right next to a flood was more than three times faster than typing with nothing else going on.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;how is ship faster during a flood that without a flood? wtf?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;My agent&apos;s guess was that a quiet machine lets its cores go to sleep, and waking a sleepy core costs time on every hop. To check, we ran &lt;code dir=&quot;auto&quot;&gt;yes &gt; /dev/null&lt;/code&gt; in the background to keep the cores busy without touching Ship.&lt;/p&gt;
&lt;table tabindex=&quot;0&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;mux&lt;/th&gt;
&lt;th&gt;quiet machine&lt;/th&gt;
&lt;th&gt;with &lt;code dir=&quot;auto&quot;&gt;yes&lt;/code&gt; running&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ship&lt;/td&gt;
&lt;td&gt;1.80 ms&lt;/td&gt;
&lt;td&gt;0.41 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Herdr&lt;/td&gt;
&lt;td&gt;0.67 ms&lt;/td&gt;
&lt;td&gt;0.13 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Both got four or five times faster just by having the CPU awake. So technically the way to make typing in Ship faster is to run &lt;code dir=&quot;auto&quot;&gt;yes &gt; /dev/null&lt;/code&gt; in another tab, which is the kind of fix I&apos;d reject in code review. Part of the idle number isn&apos;t Ship, it&apos;s the hardware napping between keystrokes, and my agent figured Ship pays it more often because a keystroke hops between more threads than in Herdr.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;the-wake-up-that-came-too-early&quot;&gt;The wake-up that came too early&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;While tracing single keystrokes, my agent noticed that on 149 out of 151 keys the pane got woken up to redraw before the echo even existed.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;µs after keypress&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;   &lt;/span&gt;&lt;/span&gt;&lt;span&gt;0   key arrives&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;414   written to the program&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;431   pane wakes up and redraws (nothing changed)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;462   publishes the same screen again&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;479   program echoes the letter          ← the pane is busy&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;539   pane finally sees the echo&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;652   redraw done&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;676   the screen with your letter goes out&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Every key scrolls the view back to the bottom and clears any selection, which is what you&apos;d want if you&apos;d scrolled up. But the code asked for a redraw every time, even when the view was already at the bottom with nothing selected, which is pretty much always. So the pane spent its time redrawing an unchanged screen right as your letter showed up. The fix only asks for a redraw when something moved.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;fn&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;input_bottom&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&amp;#x26;mut&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;self&lt;/span&gt;&lt;span&gt;, needs_render: &lt;/span&gt;&lt;span&gt;&amp;#x26;mut&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;bool&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;// Usually the view is already at the bottom with nothing selected,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;// so nothing changes and a render would only delay the echo.&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;let&lt;/span&gt;&lt;span&gt; scrolled = !&lt;/span&gt;&lt;span&gt;self&lt;/span&gt;&lt;span&gt;.terminal.&lt;/span&gt;&lt;span&gt;viewport_active&lt;/span&gt;&lt;span&gt;().&lt;/span&gt;&lt;span&gt;unwrap_or&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;false&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;let&lt;/span&gt;&lt;span&gt; selected = &lt;/span&gt;&lt;span&gt;self&lt;/span&gt;&lt;span&gt;.terminal.&lt;/span&gt;&lt;span&gt;selection&lt;/span&gt;&lt;span&gt;().&lt;/span&gt;&lt;span&gt;map_or&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;, |s| s.&lt;/span&gt;&lt;span&gt;is_some&lt;/span&gt;&lt;span&gt;());&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;self&lt;/span&gt;&lt;span&gt;.terminal.&lt;/span&gt;&lt;span&gt;scroll_viewport&lt;/span&gt;&lt;span&gt;(ScrollViewport::&lt;/span&gt;&lt;span&gt;Bottom&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;self&lt;/span&gt;&lt;span&gt;.terminal.&lt;/span&gt;&lt;span&gt;set_selection&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;None&lt;/span&gt;&lt;span&gt;).&lt;/span&gt;&lt;span&gt;ok&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;*needs_render = &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;*needs_render |= scrolled || selected;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Typing beside a flood went from 0.49 ms to 0.38 ms at p50, and the slowest 1% went from about 3.5 ms to about 0.65 ms.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;holy crap... crazy good free optimization&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Each key was also publishing two screens instead of one, but fixing that made no difference to latency, so I didn&apos;t expect much from the window&apos;s drawing either.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;as for the client draw. you can take a look at it, but i&apos;d be surprised if there&apos;s anything we can realistically shave off there without introducing a mountain of complexity&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;But ratatui, the library Ship&apos;s window draws with, was asking for the terminal size on every frame, and getting the size meant opening &lt;code dir=&quot;auto&quot;&gt;/dev/tty&lt;/code&gt;, asking, and closing it again. That was 101 µs per draw, and Ship now sizes the window from resize events instead.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// Fixed, so drawing doesn&apos;t reopen `/dev/tty` for the size every&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// frame; `drive` sizes it from resize events.&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;TerminalOptions&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;viewport: &lt;/span&gt;&lt;span&gt;Viewport&lt;/span&gt;&lt;span&gt;::&lt;/span&gt;&lt;span&gt;Fixed&lt;/span&gt;&lt;span&gt;(Rect::&lt;/span&gt;&lt;span&gt;default&lt;/span&gt;&lt;span&gt;()),&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;That took a draw from 101 µs to 16 µs.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;does the size thing cost us anything? would there ever be a chance where we miss an event or something and it would get all wonky and weird?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;To make sure a fixed size couldn&apos;t go stale, my agent made a build that ignored resizes on purpose and checked that the test caught it.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;9-where-the-last-millisecond-goes&quot;&gt;9. Where the last millisecond goes&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Even after all that, a quiet keystroke took about twice as long in Ship as in Herdr, and when my agent broke it down it was about 400 µs each way between the window and the server.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Why does it take nearly 400us each way? that seems absolutely nuts. if we could half that somehow, then that would get us pretty close to herdr i think?..&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;My agent&apos;s theory was thread wake-ups. A keypress goes from the window&apos;s UI thread to its network thread and then over to the server, and each handoff wakes up another thread, and if each wake costs 50 to 150 µs on a sleepy core, that&apos;s most of the gap. The window ran on tokio&apos;s multi-threaded runtime, so the fix would be to run it on a single thread and skip the handoffs.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;i guess it&apos;s worth a probe, but i don&apos;t really expect much out of it.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;And it did pretty much nothing.&lt;/p&gt;
&lt;table tabindex=&quot;0&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;median µs, sleepy cores&lt;/th&gt;
&lt;th&gt;many threads (19)&lt;/th&gt;
&lt;th&gt;one thread (3)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;key handed off&lt;/td&gt;
&lt;td&gt;122 / 123&lt;/td&gt;
&lt;td&gt;141 / 138&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;reaches the server&lt;/td&gt;
&lt;td&gt;192 / 189&lt;/td&gt;
&lt;td&gt;174 / 186&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;screen back out&lt;/td&gt;
&lt;td&gt;156 / 154&lt;/td&gt;
&lt;td&gt;162 / 159&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;full round trip&lt;/td&gt;
&lt;td&gt;2196 / 2030&lt;/td&gt;
&lt;td&gt;2128 / 2134&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;A handoff that took 120 µs with 19 threads took 120 µs with one, so it wasn&apos;t a wake-up.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;does compiling with debug symbols plus profiling not help at all? or tracing logs?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;My agent explained why they can&apos;t see this kind of problem. A profiler only sees a thread while it&apos;s busy and a trace log only sees the moments you added a line for, so adding more trace points just narrows the gap until it falls between two lines with nothing to log, and neither one sees why a thread was sitting there doing nothing.&lt;/p&gt;
&lt;p&gt;What does see it is a scheduler trace, which records every time the OS puts a thread to sleep, wakes it up or moves it to another core. macOS has one built into Instruments, and it works without sudo. The first try missed most threads because of how the trace file nests things, then everything turned out to be running on efficiency cores, and the automatic wake-chain walker followed a TCP acknowledgement back to the wrong place, so in the end my agent walked a couple of keystrokes by hand, and it showed the theory was wrong, because waking a thread takes about 3 to 10 µs, not 100 or more.&lt;/p&gt;
&lt;table tabindex=&quot;0&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;median µs, one keystroke&lt;/th&gt;
&lt;th&gt;Ship&lt;/th&gt;
&lt;th&gt;Herdr&lt;/th&gt;
&lt;th&gt;gap&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;key reaches the server&lt;/td&gt;
&lt;td&gt;210&lt;/td&gt;
&lt;td&gt;125&lt;/td&gt;
&lt;td&gt;85&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;program answers&lt;/td&gt;
&lt;td&gt;246&lt;/td&gt;
&lt;td&gt;235&lt;/td&gt;
&lt;td&gt;~0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;new screen reaches window&lt;/td&gt;
&lt;td&gt;398&lt;/td&gt;
&lt;td&gt;189&lt;/td&gt;
&lt;td&gt;209&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;window draws it&lt;/td&gt;
&lt;td&gt;429&lt;/td&gt;
&lt;td&gt;46&lt;/td&gt;
&lt;td&gt;383&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;total&lt;/td&gt;
&lt;td&gt;1293&lt;/td&gt;
&lt;td&gt;618&lt;/td&gt;
&lt;td&gt;675&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The &quot;wake-ups&quot; were threads busy running, and Herdr hands off between threads about as many times as Ship does. The biggest gap was the last step, where Ship&apos;s window takes 429 µs to draw a keystroke and Herdr&apos;s takes 46.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;what-herdr-does-differently&quot;&gt;What Herdr does differently&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Around here I was running out of usage for the week.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;bro i&apos;m about to run out of usage. can you wrap up soon? i have 1% weekly usage left lol&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;But I had one more question first.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;have you checked herdr&apos;s drawing logic to see what magical things they do?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It hadn&apos;t, and we&apos;d also been benchmarking Herdr with its defaults without checking what those were, which was funny, because the day before I&apos;d already gotten mad about exactly that.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;this is exactly WHY IM SAYING TO CHECK THE CODE for herdr and trace how it actually works end to end for input and rendering...&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;So my agent finally read it. Plain &lt;code dir=&quot;auto&quot;&gt;herdr&lt;/code&gt; turns out to be just like Ship, a server that sends screens and a client that draws them, and its full redraw looks a lot like Ship&apos;s. The difference is a fast path next to it.&lt;/p&gt;
&lt;p&gt;When only one pane changed, Herdr&apos;s server sends just the rows that changed, and the client skips building the frame and writes those rows straight to your terminal. It only does that when nothing else could&apos;ve changed, so there are about 23 conditions that kick it back to the full redraw, like an overlay being open, text being selected, copy mode, a notification, hovering a link, or a patch that arrived out of order. All of that takes about 5,800 lines.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;Ship, every keystroke                      Herdr, fast path&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;build the whole frame        ~250 µs       (skipped)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;diff it against the last one ~110 µs       (skipped, server named the rows)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;write the changed cells       ~50 µs       write the changed rows ~50 µs&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;blockquote&gt;
&lt;p&gt;so it&apos;s basically like moving part of the diffing logic out of ratatui and into our logic?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Partly, but mostly it skips the rebuild, and doing the same in Ship would mean writing into ratatui&apos;s buffers behind its back.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;oh man that&apos;s really ugly... that feels like we&apos;re reaching into it&apos;s guts or something... although in theory... i&apos;m confused as to why ratatui doesn&apos;t already do that? why is it wasting time diffing everything? if you compared it to the web world, is it using a model closer to react than to something like solid?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It&apos;s React. ratatui works like React without &lt;code dir=&quot;auto&quot;&gt;memo&lt;/code&gt;, so it redraws everything every frame and then compares the result with the last frame to find what to write, and it&apos;s the reason Ship does diff screens after all, just at the end, between two frames sitting in memory on your machine. That&apos;s why it never misses a change, since it looks at everything, but it means rebuilding a frame that&apos;s 99% the same as the last one just so the diff can find out the sidebar didn&apos;t move. Herdr&apos;s fast path is more like Solid, which tracks what depends on what, except written by hand for one case. On the web the browser does that tracking for you, but a terminal doesn&apos;t have a browser, so somebody has to write it.&lt;/p&gt;
&lt;p&gt;And Herdr&apos;s issue tracker shows what that costs. A lot of its bugs come down to something falling off the fast path when it shouldn&apos;t, or staying on it when it shouldn&apos;t, like &lt;a href=&quot;https://github.com/herdrdev/herdr/issues/3745&quot;&gt;234 KB going out per keystroke over SSH&lt;/a&gt;, &lt;a href=&quot;https://github.com/herdrdev/herdr/issues/4264&quot;&gt;3.6 GB an hour from a spinner&lt;/a&gt;, and &lt;a href=&quot;https://github.com/herdrdev/herdr/issues/4808&quot;&gt;rows that never repainted after a background color change&lt;/a&gt;.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;it&apos;s like a messy spider web that would rather shave off 1ms by handling every possible edge case manually?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;And all that work gets Herdr a few hundred microseconds per keystroke.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;even after all of that effort, all we really save is 200 microseconds... honestly, i&apos;m more impressed that herdr was willing to spend the effort to squeeze out all of those extra microseconds on latency. only to be beaten on cpu anyways.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I&apos;d also been noticing Herdr stutter now and then in my own use, though I can&apos;t say it was any of these.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;ngl... i definitely notice weird stutters with herdr for some reason. haven&apos;t used ship much, but it seems to stutter way less often. would need heavier usage before i can feel like i can call it a fair comparison though&lt;/p&gt;
&lt;/blockquote&gt;
&lt;div&gt;&lt;h3 id=&quot;the-fast-renderer-i-threw-away&quot;&gt;The fast renderer I threw away&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;I still wanted to know what Ship could get, so my agent built a version of the window that only redraws what changed. On its own it was about 30 times faster at drawing a keystroke, 325 µs down to 10, and a randomized checker ran it against the normal renderer for 2 million frames without a mismatch. Plugged into Ship, it looked like this.&lt;/p&gt;
&lt;table tabindex=&quot;0&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;measure&lt;/th&gt;
&lt;th&gt;normal&lt;/th&gt;
&lt;th&gt;only what changed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;window draw per keystroke&lt;/td&gt;
&lt;td&gt;334 µs&lt;/td&gt;
&lt;td&gt;83 µs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;window CPU with an agent&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;51% less&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;typing (idle)&lt;/td&gt;
&lt;td&gt;1.36 ms&lt;/td&gt;
&lt;td&gt;1.09 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;
&lt;p&gt;holy crap. no fucking way... so this is basically matching herdr?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It was the feeling of every test passing before you&apos;ve looked at the diff. Then I read the code.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;i&apos;m not gonna lie... that client code looks absolutely abysmal to read. like pure slop... especially with all of the layering logic...&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;And for what it actually got us, I couldn&apos;t justify it.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;it does seem to be better, but it&apos;s way too much effort and weirdness for like... shaving off 200us at best, no?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;A frame on a 120 Hz screen is 8.3 ms, and nobody feels 1.3 ms versus 0.6 ms on a keystroke.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;you can&apos;t even notice the 1ms overhead. but you can definitely notice the bugs as a user and the maintenance burden as a dev.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It&apos;s still on the &lt;code dir=&quot;auto&quot;&gt;experiment/retained-render&lt;/code&gt; branch.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;10-the-memory-nobody-asked-for&quot;&gt;10. The memory nobody asked for&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Herdr was also using less memory than Ship, which bugged me.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;wonder how herdr does less memory... that&apos;s interesting. because i&apos;d figured that we&apos;re pretty efficient?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Sitting idle, Ship&apos;s server used 16 MB and its window 10 MB, against 11 MB and 6.3 MB for Herdr, and both of them use Ghostty, so Ship was spending an extra 9 MB on something.&lt;/p&gt;
&lt;p&gt;My agent&apos;s first guess was that tokio starts too many threads, and capping them would&apos;ve saved a few MB.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;honestly... it feels so small that i feel like i&apos;d leave it. maybe the client can work fine with less, but i feel like it&apos;s not really a noticable cost? like who&apos;s fighting over 4-5MB of memory on a 16 core machine?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Its second guess was that Herdr uses an older Ghostty, which turned out to be backwards, because Herdr&apos;s Ghostty was newer. The answer was in how big each thread is. Every thread gets its own copy of the program&apos;s thread-local storage, and Ship&apos;s was a lot bigger.&lt;/p&gt;
&lt;table tabindex=&quot;0&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;thread-local storage per thread&lt;/th&gt;
&lt;th&gt;size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Herdr&lt;/td&gt;
&lt;td&gt;584 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ship&lt;/td&gt;
&lt;td&gt;263 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The version of Ghostty that Ship used pulled in a piece of Zig&apos;s standard library that sets aside a 256 KiB stack per thread for handling signals, and every one of Ship&apos;s threads got one, Ghostty&apos;s or not. Upstream Ghostty had already dropped that piece of Zig back in August, so the fix was just a version bump, and the window shrank the most, which confused me.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;why the heck did the client go down so much? wtf? that&apos;s nuts?&lt;/p&gt;
&lt;p&gt;why did the client have zig stuff in it?... huh?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The window is the same program as the server, so it carries Ghostty too, and over half the window&apos;s memory had been 19 copies of a spare stack nobody ever used.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;well damn... that&apos;s pretty sick... so with 0 code changes at all, we&apos;re better off than herdr now?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It wasn&apos;t quite zero, there was the version bump and one deleted &lt;code dir=&quot;auto&quot;&gt;Ok(())&lt;/code&gt;.&lt;/p&gt;
&lt;table tabindex=&quot;0&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;peak memory, everything included&lt;/th&gt;
&lt;th&gt;Herdr&lt;/th&gt;
&lt;th&gt;Ship before&lt;/th&gt;
&lt;th&gt;Ship after&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;idle&lt;/td&gt;
&lt;td&gt;39.7 MB&lt;/td&gt;
&lt;td&gt;41.8 MB&lt;/td&gt;
&lt;td&gt;30.9 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;agent printing&lt;/td&gt;
&lt;td&gt;42.2 MB&lt;/td&gt;
&lt;td&gt;46.5 MB&lt;/td&gt;
&lt;td&gt;35.6 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;log flood&lt;/td&gt;
&lt;td&gt;50.6 MB&lt;/td&gt;
&lt;td&gt;55.8 MB&lt;/td&gt;
&lt;td&gt;43.8 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;full-screen animation&lt;/td&gt;
&lt;td&gt;44.7 MB&lt;/td&gt;
&lt;td&gt;53.7 MB&lt;/td&gt;
&lt;td&gt;43.8 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Ship went from losing every row to winning every row, with CPU and typing where they were. The one place Herdr can still come out ahead is a long-lived server with a lot of scrollback, since Ship keeps 10 MB of history per pane.&lt;/p&gt;
&lt;p&gt;At that point the list felt done.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;so no more real levers to pull?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;div&gt;&lt;h2 id=&quot;11-one-more-look&quot;&gt;11. One more look&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;About ten days later I still had the idle keystroke on my mind, and a theory about my laptop&apos;s cores.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;when the cpu isn&apos;t under load it actually uses different cores than when it is. and i think that ship actually benefits from that way more.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Checking that meant following one keystroke all the way through, and Ship doesn&apos;t know anything about the program running in the pane.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;how the hell are we going to be able to trace a key through the input and back out the output in a way that keeps the correct dumb pipe architecture where ship works no matter what program is running in the tty?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;So my agent built a trace that watches from the outside. The program in the pane timestamps each read and write, the window and the server mark the edges they own, and a script combines all three into one timeline. That had its own problems. The idea of tagging each key with an ID died right away because back then all input went over one long request, and the trace tool writes every process as process 1, so the files had to be renumbered before they&apos;d line up. Ship stays a dumb pipe with timing only at the edges it owns, so the tracing added three lines to the code.&lt;/p&gt;
&lt;table tabindex=&quot;0&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;step&lt;/th&gt;
&lt;th&gt;µs after keypress&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;window sends it&lt;/td&gt;
&lt;td&gt;71&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;server writes it to the pty&lt;/td&gt;
&lt;td&gt;326&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;program reads it&lt;/td&gt;
&lt;td&gt;404&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;program writes the echo&lt;/td&gt;
&lt;td&gt;456&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ghostty has parsed it&lt;/td&gt;
&lt;td&gt;499&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;screen published&lt;/td&gt;
&lt;td&gt;552&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;window receives it&lt;/td&gt;
&lt;td&gt;1141&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;window starts drawing&lt;/td&gt;
&lt;td&gt;1152&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;window done drawing&lt;/td&gt;
&lt;td&gt;1790&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;your terminal has it&lt;/td&gt;
&lt;td&gt;1859&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The work in there is small, about 5 µs to parse the echo, 4 to publish, 13 to encode, 20 to decode and 7 to apply. Most of the rest is time spent waiting for the next thread to get to it, including 369 µs between publishing and the stream to the window noticing.&lt;/p&gt;
&lt;p&gt;For the efficiency core theory, my agent wanted to tell macOS to treat Ship&apos;s threads as user-interactive.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;does that actually help at all? i guess we can give it a shot, but i don&apos;t expect much&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That bumped their priority from 31 to 37. Idle typing went from 1.58 ms to 1.51 ms, which is noise, so that got reverted. Running the window on a single thread came up again too.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;doesn&apos;t help. i already tried it before&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;so seems like we&apos;re optimal-ish?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Pretty much. What&apos;s left is the design itself, a separate server and window talking over HTTP. That&apos;s the part that lets you close the window and keep working, or attach from another machine, and I&apos;ll take that over half a millisecond.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;12-the-final-race&quot;&gt;12. The final race&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The last benchmark ran all four, tmux included this time.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;never used tmux before so i had to install it just now. let&apos;s give it a go and see!&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Five rounds, 180 runs in shuffled order, on an M4 Max.&lt;/p&gt;
&lt;table tabindex=&quot;0&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;medians&lt;/th&gt;
&lt;th&gt;Ship&lt;/th&gt;
&lt;th&gt;Herdr&lt;/th&gt;
&lt;th&gt;Zellij&lt;/th&gt;
&lt;th&gt;tmux&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;log flood&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;198 MB/s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;126 MB/s&lt;/td&gt;
&lt;td&gt;40 MB/s&lt;/td&gt;
&lt;td&gt;40 MB/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;wide-text flood&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;139 MB/s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;85 MB/s&lt;/td&gt;
&lt;td&gt;56 MB/s&lt;/td&gt;
&lt;td&gt;9 MB/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;animation CPU&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;29%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;41%&lt;/td&gt;
&lt;td&gt;65%&lt;/td&gt;
&lt;td&gt;45%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;animation fps&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;60&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;53&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;60&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;48&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU while typing in flood&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;82%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;102%&lt;/td&gt;
&lt;td&gt;206%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;typing during a flood&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.36 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.57 ms&lt;/td&gt;
&lt;td&gt;8.5 ms&lt;/td&gt;
&lt;td&gt;0.53 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;typing during a flood p99&lt;/td&gt;
&lt;td&gt;8.3 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.4 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;12.5 ms&lt;/td&gt;
&lt;td&gt;9.0 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;typing (idle)&lt;/td&gt;
&lt;td&gt;1.66 ms&lt;/td&gt;
&lt;td&gt;0.64 ms&lt;/td&gt;
&lt;td&gt;13.4 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.33 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;agent screen CPU&lt;/td&gt;
&lt;td&gt;1.7%&lt;/td&gt;
&lt;td&gt;1.2%&lt;/td&gt;
&lt;td&gt;1.3%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.25%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;memory (idle)&lt;/td&gt;
&lt;td&gt;35 MB&lt;/td&gt;
&lt;td&gt;48 MB&lt;/td&gt;
&lt;td&gt;108 MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10 MB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;tmux, which I&apos;d only installed that day, won idle typing and memory by a mile. It&apos;s a lot like spending two weeks optimizing something and having &lt;code dir=&quot;auto&quot;&gt;brew install&lt;/code&gt; beat it.&lt;/p&gt;
&lt;p&gt;When there&apos;s a lot going on, Ship gets through it faster and with less CPU than the rest. The animation is a full-screen truecolor gradient, which is about as hard on compression as a normal screen gets.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;when it came to the anim test, what was the methodology for that? was it with gradients and we STILL won by THAT much?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Herdr also uses Ghostty, so the gap in the animation row comes from what happens after parsing. When not much is happening, Ship uses a bit more CPU and a quiet keystroke takes a bit longer, and you can see where that goes if you split the keystroke in half.&lt;/p&gt;
&lt;table tabindex=&quot;0&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;typing (idle), median&lt;/th&gt;
&lt;th&gt;key in&lt;/th&gt;
&lt;th&gt;program&lt;/th&gt;
&lt;th&gt;screen out&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ship&lt;/td&gt;
&lt;td&gt;0.31 ms&lt;/td&gt;
&lt;td&gt;0.04 ms&lt;/td&gt;
&lt;td&gt;1.31 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Herdr&lt;/td&gt;
&lt;td&gt;0.28 ms&lt;/td&gt;
&lt;td&gt;0.05 ms&lt;/td&gt;
&lt;td&gt;0.30 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tmux&lt;/td&gt;
&lt;td&gt;0.13 ms&lt;/td&gt;
&lt;td&gt;0.05 ms&lt;/td&gt;
&lt;td&gt;0.15 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Getting the key in is about the same for everyone, and it&apos;s the way back out, through the server, the stream and the full redraw, that costs Ship its millisecond.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;the thing is that to me, this feels like the right trade off. because on light workloads, you won&apos;t notice 1-2% difference in cpu utilization, but on heavy ones, you will certainly notice the drastic performance drop off.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The slowest 1% while typing beside a flood is the one loss I can&apos;t explain yet.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;a-screen-that-never-repeats&quot;&gt;A screen that never repeats&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;All of the above depends on screens repeating themselves, so I added a workload that doesn&apos;t, every cell a random color 60 times a second.&lt;/p&gt;
&lt;table tabindex=&quot;0&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;over the wire or to the terminal&lt;/th&gt;
&lt;th&gt;Ship over the wire&lt;/th&gt;
&lt;th&gt;tmux to the terminal&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;16 MiB log flood&lt;/td&gt;
&lt;td&gt;15 KB total&lt;/td&gt;
&lt;td&gt;520 KB total&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;a keystroke in a quiet pane&lt;/td&gt;
&lt;td&gt;18 B&lt;/td&gt;
&lt;td&gt;11 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;normal animation&lt;/td&gt;
&lt;td&gt;110 KB/s&lt;/td&gt;
&lt;td&gt;8.8 MB/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;noise&lt;/td&gt;
&lt;td&gt;2.9 MB/s&lt;/td&gt;
&lt;td&gt;8.8 MB/s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Ship sends a lot more for noise, but still about a third of what tmux writes, and that&apos;s the number that matters if you use either over ssh, because Ship&apos;s window can already connect to a server on another machine through an ssh tunnel, and with tmux everything it draws goes through ssh as is.&lt;/p&gt;
&lt;div role=&quot;note&quot;&gt;&lt;span aria-hidden=&quot;true&quot;&gt;&lt;/span&gt;&lt;div&gt;&lt;p&gt;To try that, forward the other machine&apos;s server socket over ssh and point &lt;code dir=&quot;auto&quot;&gt;ship&lt;/code&gt; at it from another terminal. &lt;code dir=&quot;auto&quot;&gt;ship server status&lt;/code&gt; on the other machine prints the socket&apos;s path, and &lt;a href=&quot;https://shipmux.github.io/ship/docs/windows-and-the-server/#using-ship-on-another-machine&quot;&gt;Using Ship on another machine&lt;/a&gt; has the details.&lt;/p&gt;&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;ssh &lt;/span&gt;&lt;span&gt;-N&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;-o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;StreamLocalBindUnlink=yes&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;-L&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;~/.ship-otherbox.sock:/run/user/1000/ship/default.sock&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;otherbox&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;ship &lt;/span&gt;&lt;span&gt;--server-url&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;unix://&lt;/span&gt;&lt;span&gt;$HOME&lt;/span&gt;&lt;span&gt;/.ship-otherbox.sock&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div&gt;&lt;h2 id=&quot;13-one-more-pass-before-the-release&quot;&gt;13. One more pass before the release&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A week after that race, a couple of things had changed underneath it. The window and the server stopped talking over a TCP port and started using a Unix socket only my user can open, and every keystroke became its own numbered request instead of a line on one long stream. I&apos;d also cloned Ship onto my Linux machine and run the benchmark there.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;so basically we&apos;re still winning by about the same as on macos?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Mostly. Ship had the same wins and the same losses on both, and everything was faster on that machine, so the gaps barely moved. The flood lead over Herdr shrank from 1.6x to 1.3x, and a quiet keystroke was still about twice Herdr&apos;s and five times tmux&apos;s.&lt;/p&gt;
&lt;p&gt;With the first release close, I wanted one more profiling run, and this time across everything instead of just the slow tail.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;do you think we could investigate all of the major workloads? not JUST p99? that way we don&apos;t miss other potential improvements that only show up on other paths?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Every time the server published a screen, it copied all 10,000 cells of the pane and converted all 50 rows, even when a keystroke had changed one of them. Ghostty already renders only the rows that changed, so now the widget remembers which rows it wrote and the server copies and converts just those. What gets published is still the whole screen, compressed the same way as before, so everything in section 3 still holds.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;how do you know which rows are dirty though? doesn&apos;t that have the risk of desync?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It would if that record were ever wrong, so debug builds compare the copy against a full render after every capture. In those runs, idle typing went from 1.87 ms to 1.54 ms and the agent screen&apos;s CPU from 1.76% to 1.35%.&lt;/p&gt;
&lt;p&gt;The rest was smaller. The window used to redraw for each keystroke and again when the new screen showed up, and now it only redraws when a screen arrives, which took a tab switch from three redraws to one. Starting Ship with no server running took 113 ms because it checked for the server every 100 ms, and now it checks every 5 ms and takes 23.&lt;/p&gt;
&lt;p&gt;The input side was mostly threads waking each other up, and my agent&apos;s idea for saving a hop there was to go back to one long stream for input.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;i already DID try the long lived input stream thing. it was just really awkward and didn&apos;t really play very well with actions. it&apos;s literally the reason i built the whole order thing&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;On the output side, ratatui&apos;s diff, which works out what the window actually has to send to your terminal, checked how wide every one of the 10,000 cells was before checking whether it had changed at all, and swapping those two checks made typing on a busy screen about 0.1 ms faster.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;that actually so funny... really? we get that much of a boost from moving one line a branch down and getting rid of another?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The swapped version was only faster because it was wrong, since when a border overwrote the second half of a wide character it stopped noticing, and the version that got that right gained nothing.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;i&apos;m guessing that means that we can&apos;t actually make it faster?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Not from there. Even a free diff would only save a tenth of a millisecond, and the rest is the same two-process design from section 9.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;welp... i don&apos;t think we&apos;re gonna be able to squeeze much more out of this... how&apos;s about we call it here for the perf stuff and open the pr?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;So I reran the race from section 12 on the merged code, same laptop, same five rounds, with one change to the benchmark itself.&lt;/p&gt;
&lt;table tabindex=&quot;0&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;medians&lt;/th&gt;
&lt;th&gt;Ship before&lt;/th&gt;
&lt;th&gt;Ship now&lt;/th&gt;
&lt;th&gt;Herdr&lt;/th&gt;
&lt;th&gt;Zellij&lt;/th&gt;
&lt;th&gt;tmux&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;log flood&lt;/td&gt;
&lt;td&gt;198 MB/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;206 MB/s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;124 MB/s&lt;/td&gt;
&lt;td&gt;40 MB/s&lt;/td&gt;
&lt;td&gt;40 MB/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;wide-text flood&lt;/td&gt;
&lt;td&gt;139 MB/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;141 MB/s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;84 MB/s&lt;/td&gt;
&lt;td&gt;56 MB/s&lt;/td&gt;
&lt;td&gt;9 MB/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;animation CPU&lt;/td&gt;
&lt;td&gt;29%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;27%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;41%&lt;/td&gt;
&lt;td&gt;66%&lt;/td&gt;
&lt;td&gt;47%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;animation fps&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;60&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;54&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;60&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;60&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU while typing in flood&lt;/td&gt;
&lt;td&gt;82%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;81%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;102%&lt;/td&gt;
&lt;td&gt;207%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;typing during a flood&lt;/td&gt;
&lt;td&gt;0.36 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.33 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.57 ms&lt;/td&gt;
&lt;td&gt;9.6 ms&lt;/td&gt;
&lt;td&gt;0.53 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;typing during a flood p99&lt;/td&gt;
&lt;td&gt;8.3 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.62 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.2 ms&lt;/td&gt;
&lt;td&gt;12.5 ms&lt;/td&gt;
&lt;td&gt;0.90 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;typing (idle)&lt;/td&gt;
&lt;td&gt;1.66 ms&lt;/td&gt;
&lt;td&gt;1.40 ms&lt;/td&gt;
&lt;td&gt;0.62 ms&lt;/td&gt;
&lt;td&gt;13.5 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.32 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;agent screen CPU&lt;/td&gt;
&lt;td&gt;1.7%&lt;/td&gt;
&lt;td&gt;1.2%&lt;/td&gt;
&lt;td&gt;1.6%&lt;/td&gt;
&lt;td&gt;1.3%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.25%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;memory (idle)&lt;/td&gt;
&lt;td&gt;35 MB&lt;/td&gt;
&lt;td&gt;41 MB&lt;/td&gt;
&lt;td&gt;48 MB&lt;/td&gt;
&lt;td&gt;108 MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10 MB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The change to the benchmark is in the p99 row, and it&apos;s the answer to the loss I couldn&apos;t explain. Once a second the benchmark ran &lt;code dir=&quot;auto&quot;&gt;ps&lt;/code&gt; to check everyone&apos;s memory, and while it did, its own reader stalled for 10 to 20 ms, so any key typed in that moment looked slow no matter which multiplexer it went through. With that turned off for the typing runs, the slowest 1% beside a flood went from 8.3 ms to 0.62 ms for Ship, from 9.0 to 0.90 for tmux, and Ship has the best tail of the four. tmux&apos;s animation also turns out to have been running at 60 fps all along.&lt;/p&gt;
&lt;p&gt;A quiet keystroke split in half again shows where the 0.26 ms came from, and where some of it went.&lt;/p&gt;
&lt;table tabindex=&quot;0&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;typing (idle), median&lt;/th&gt;
&lt;th&gt;key in&lt;/th&gt;
&lt;th&gt;screen out&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ship before&lt;/td&gt;
&lt;td&gt;0.31 ms&lt;/td&gt;
&lt;td&gt;1.31 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ship now&lt;/td&gt;
&lt;td&gt;0.54 ms&lt;/td&gt;
&lt;td&gt;0.81 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Herdr&lt;/td&gt;
&lt;td&gt;0.28 ms&lt;/td&gt;
&lt;td&gt;0.29 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Half a millisecond came off the way back out, and about a quarter of it went back on the way in, most likely because every key is its own request now. Memory is up by about 6 MB across the board, and I haven&apos;t tracked down why yet.&lt;/p&gt;
&lt;p&gt;Over the wire, the noise screen still costs Ship 2.9 MB/s, but at a real 60 fps tmux writes 11 MB/s, so that&apos;s about a quarter of tmux now instead of a third. A quiet keystroke went the other way, from 18 B to 67 B, since each key now gets its own small reply.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-it-came-down-to&quot;&gt;What it came down to&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The part I was most worried about, sending the whole screen every time, worked fine the whole way and never needed fixing. Every win came from waste around it, like a timer that rounded up, screen copies nobody looked at, a thread woken 91,000 times a second, a redraw that came in right as your letter did and 256 KiB of stack per thread that nobody used, and most of those fixes were a few lines. Ship is still about 0.8 ms behind Herdr on a quiet keystroke, and closing that would take something like Herdr&apos;s fast path, which I&apos;d rather not have.&lt;/p&gt;
&lt;p&gt;These numbers are from my laptop, and the benchmark is in &lt;a href=&quot;https://github.com/shipmux/ship/tree/main/benchmarks&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;benchmarks/&lt;/code&gt;&lt;/a&gt; if you want to run it on yours.&lt;/p&gt;
&lt;div role=&quot;note&quot;&gt;&lt;span aria-hidden=&quot;true&quot;&gt;&lt;/span&gt;&lt;div&gt;&lt;p&gt;It needs &lt;a href=&quot;https://docs.astral.sh/uv/&quot;&gt;uv&lt;/a&gt; and a release build of Ship, and a full run takes about 40 minutes. Leave out anything you don&apos;t have installed with &lt;code dir=&quot;auto&quot;&gt;--mux&lt;/code&gt;.&lt;/p&gt;&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;cargo &lt;/span&gt;&lt;span&gt;build&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;--release&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;cd &lt;/span&gt;&lt;span&gt;benchmarks&lt;/span&gt;&lt;span&gt; &amp;#x26;&amp;#x26; uv &lt;/span&gt;&lt;span&gt;run&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;bench.py&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;--mux&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ship&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;tmux&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;--rss-sample-s&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;
</content:encoded></item></channel></rss>