<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: xbill</title>
    <description>The latest articles on DEV Community by xbill (@xbill).</description>
    <link>https://dev.to/xbill</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3490099%2Fc6a975d0-cd94-485d-82b1-14ed5b344fcf.jpg</url>
      <title>DEV Community: xbill</title>
      <link>https://dev.to/xbill</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/xbill"/>
    <language>en</language>
    <item>
      <title>The Desktop Looked Right: 2,093 Parse Errors a Boot, and 10.9 Seconds That Weren't Doing Anything</title>
      <dc:creator>xbill</dc:creator>
      <pubDate>Tue, 08 Sep 2026 18:05:25 +0000</pubDate>
      <link>https://dev.to/gde/the-desktop-looked-right-2093-parse-errors-a-boot-and-109-seconds-that-werent-doing-anything-4bb</link>
      <guid>https://dev.to/gde/the-desktop-looked-right-2093-parse-errors-a-boot-and-109-seconds-that-werent-doing-anything-4bb</guid>
      <description>&lt;p&gt;This article walks through one afternoon of Debian system management, in which the machine turned out to have been reporting two of its own defects continuously, for as long as it had been configured, to nobody.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Debian tells you almost everything. It just does not tell you on screen.&lt;/strong&gt; The kernel ring buffer, the systemd journal, &lt;code&gt;systemd-analyze&lt;/code&gt;, &lt;code&gt;dpkg&lt;/code&gt;, &lt;code&gt;apt-cache policy&lt;/code&gt; and &lt;code&gt;/sys&lt;/code&gt; between them describe the state of the machine in far more detail than any settings panel — and a desktop can look completely correct while those sources are recording a failure a few hundred times a minute.&lt;/p&gt;

&lt;p&gt;What that gap creates is a feedback loop with a missing half. The machine emits the evidence; nothing reads it. The two defects below had both been present since the day the setup script was written, both were sitting in plain text in the journal, and neither was discoverable from the desktop:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The GTK theme was &lt;strong&gt;discarded at parse time&lt;/strong&gt;, 161 rules per application launch,
&lt;strong&gt;2,093 log lines in the boot I measured&lt;/strong&gt;, while reporting success and looking
approximately right.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;10.9 seconds of a 39.3-second boot&lt;/strong&gt; were spent waiting for something nothing
was waiting for.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both were introduced by &lt;code&gt;flex&lt;/code&gt;, the script in this repository whose job is to configure the machine:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/xbill9/chromeos-boot" rel="noopener noreferrer"&gt;https://github.com/xbill9/chromeos-boot&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Machine
&lt;/h2&gt;

&lt;p&gt;One laptop, measured on 2026-09-08:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hardware&lt;/td&gt;
&lt;td&gt;Lenovo Yoga 9i, Intel CometLake-H, NVIDIA GTX 1650 Ti Mobile&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OS&lt;/td&gt;
&lt;td&gt;Debian 13 (trixie)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kernel&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;7.1.8+deb13-amd64&lt;/code&gt; (from &lt;code&gt;trixie-backports&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Desktop&lt;/td&gt;
&lt;td&gt;GNOME 48, Wayland, gdm3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GTK&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;libgtk-4-1&lt;/code&gt; &lt;strong&gt;4.18.6&lt;/strong&gt;, &lt;code&gt;libadwaita-1-0&lt;/code&gt; &lt;strong&gt;1.7.6&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That GTK version is the whole article, and nothing on the desktop ever mentions it.&lt;/p&gt;

&lt;h2&gt;
  
  
  At This Point You Should Have
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A Debian desktop you have customised with something — a script, a dotfile
repo, a settings panel, an afternoon of &lt;code&gt;gsettings&lt;/code&gt; — and that appears to work&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sudo&lt;/code&gt;, or membership of &lt;code&gt;adm&lt;/code&gt; / &lt;code&gt;systemd-journal&lt;/code&gt;. &lt;strong&gt;Check this first&lt;/strong&gt;, for
reasons in Step 0&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;systemd-analyze&lt;/code&gt;, &lt;code&gt;journalctl&lt;/code&gt;, &lt;code&gt;dpkg&lt;/code&gt;, &lt;code&gt;apt-cache&lt;/code&gt; — all base install&lt;/li&gt;
&lt;li&gt;Somewhere durable to write findings that is not your shell scrollback&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of the diagnosis below changes anything. Steps 0 through 3 are reads.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 0 — Find Out Whether You Can Read the Evidence At All
&lt;/h2&gt;

&lt;p&gt;This step exists because the investigation nearly started with a wrong answer.&lt;/p&gt;

&lt;p&gt;The first &lt;code&gt;journalctl&lt;/code&gt; run reported four errors this boot, all of them GNOME session scopes failing to start, and that looked like the whole picture. It was not the whole picture. It was the picture available to an unprivileged user:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;uid=1000(xbill) gid=1000(xbill) groups=1000(xbill),24(cdrom),25(floppy),
27(sudo),29(audio),30(dip),44(video),46(plugdev),100(users),101(netdev),
102(scanner),106(bluetooth),108(lpadmin),989(docker),992(render)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;No &lt;code&gt;adm&lt;/code&gt;, no &lt;code&gt;systemd-journal&lt;/code&gt;.&lt;/strong&gt; Without either, &lt;code&gt;journalctl&lt;/code&gt; silently narrows to your own user journal rather than refusing — you get output, it is correctly formatted, and it is a small fraction of what happened. The kernel buffer is closed off separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dmesg | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dmesg: read kernel buffer failed: Operation not permitted
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference between the two views on this machine:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;View&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;err&lt;/code&gt; and above, this boot&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Unprivileged &lt;code&gt;journalctl&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sudo journalctl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;17&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Four against seventeen, and nothing in the first view indicates the other thirteen exist. &lt;strong&gt;A loop that reads a filtered log converges on a wrong answer confidently&lt;/strong&gt;, which is worse than not reading it. Add yourself to &lt;code&gt;adm&lt;/code&gt; and log back in, or accept that everything downstream needs &lt;code&gt;sudo&lt;/code&gt; — but decide it deliberately, because the failure mode is silence, not an error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — Count Before You Read
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;err&lt;/code&gt; and above is where people look, and on this machine it was almost entirely a red herring. The volume was one priority down:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;journalctl &lt;span class="nt"&gt;-b&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; err     &lt;span class="nt"&gt;--no-pager&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;journalctl &lt;span class="nt"&gt;-b&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; warning &lt;span class="nt"&gt;--no-pager&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;17
1032
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A thousand warnings is too many to read, so collapse them into shapes first. Normalising the numbers and hex out of each message turns a thousand lines into a histogram of distinct problems:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;journalctl &lt;span class="nt"&gt;-b&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; warning &lt;span class="nt"&gt;--no-pager&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s/[0-9a-f]\{8,\}/HEX/g; s/[0-9]\+/N/g'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    795 Theme parser error: libadwaita.css:N:N-N: Unknown @ rule
     27 blacklist: Duplicate blacklisted hash bin:HEX
     24 Can't update stage views actor unnamed [StBin] is on because it needs an allocation.
     10 Theme parser error: libadwaita-tweaks.css:N:N-N: Unknown @ rule
      7 Unable to get default source
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;805 of the 1,032 warnings on this system were one defect.&lt;/strong&gt; It had never appeared on screen, in any settings panel, or in the output of the script that caused it — which had reported, and still reports, success.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — Attribute the Message Before You Believe the Field
&lt;/h2&gt;

&lt;p&gt;The obvious next question is which program is producing them, and the obvious way to ask is the journal's own &lt;code&gt;_COMM&lt;/code&gt; field. That gives a wrong answer, and it is worth showing because the same trap applies to any log the agent will read.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  1288  cat
   322  gnome-control-c
   161  gnome-software
   161  xdg-desktop-por
   161  mutter-x11-fram
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cat&lt;/code&gt; is not a GTK application and does not parse stylesheets. Pulling one of those 1,288 records apart explains it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;_COMM=cat  _PID=11105  _EXE=/usr/bin/cat
MESSAGE=(chrome:11097): Gtk-WARNING **: 09:20:36.757: Theme parser error: ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The webapp launchers this repository installs pipe their stderr, so journald attributes the line to the process that &lt;em&gt;wrote&lt;/em&gt; it rather than the process that &lt;em&gt;produced&lt;/em&gt; it. The real emitter is in the message payload, in the &lt;code&gt;(name:pid)&lt;/code&gt; prefix that GLib puts there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The structured field describes the plumbing; the unstructured payload describes the event.&lt;/strong&gt; Grouping by &lt;code&gt;_COMM&lt;/code&gt; credits 1,288 parse errors to &lt;code&gt;cat&lt;/code&gt; and hides Chrome entirely. Parse the prefix instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;journalctl &lt;span class="nt"&gt;-b&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nb"&gt;cat&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"Theme parser error: libadwaita"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'s/^\(([a-zA-Z0-9_.-]+):[0-9]+\).*/\1/'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Corrected, the emitters are &lt;code&gt;gnome-control-center&lt;/code&gt;, &lt;code&gt;gnome-software&lt;/code&gt;, &lt;code&gt;xdg-desktop-portal-gtk&lt;/code&gt;, &lt;code&gt;mutter-x11-frames&lt;/code&gt; and Chrome — every GTK4 process on the system, each one failing identically, every time it starts.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Theme Declared, and What GTK Served
&lt;/h2&gt;

&lt;p&gt;161 distinct parse failures per process: 159 in &lt;code&gt;libadwaita.css&lt;/code&gt;, 2 in &lt;code&gt;libadwaita-tweaks.css&lt;/code&gt;. At &lt;code&gt;warning&lt;/code&gt; and above each one appeared exactly five times — 159 × 5 = 795, 2 × 5 = 10 — one set per GTK4 process running at boot. Across all priorities, including the Chrome instances started later from the shelf, &lt;strong&gt;2,093 lines in one boot.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The message names the construct:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Theme parser error: libadwaita.css:94:1-7: Unknown @ rule
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Columns 1-7 of line 94:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefers-color-scheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;dark&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;@define-color&lt;/span&gt; &lt;span class="n"&gt;window_bg_color&lt;/span&gt; &lt;span class="m"&gt;#222226&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;@media&lt;/code&gt;. GTK's CSS parser is not a browser's, and &lt;strong&gt;GTK 4.18 does not implement &lt;code&gt;@media&lt;/code&gt;.&lt;/strong&gt; It reports the rule as unknown and discards the entire block.&lt;/p&gt;

&lt;p&gt;Which block is discarded matters more than the noise. &lt;code&gt;flex&lt;/code&gt; installs adw-gtk3 from the upstream release tarball, pinned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep &lt;/span&gt;ADW_VERSION flex
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ADW_VERSION=${ADW_VERSION:-v6.5}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Upstream's own release notes, read in order, are unambiguous about what that pin means:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Release&lt;/th&gt;
&lt;th&gt;Upstream's note&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;@media&lt;/code&gt; in the GTK4 CSS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;v5.7&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;em&gt;"New release for GNOME 48 and libadwaita 1.7"&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v6.3&lt;/td&gt;
&lt;td&gt;last release before the change&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;v6.4&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;em&gt;"The GTK4 theme now requires GTK 4.20 or later."&lt;/em&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;v6.5&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;em&gt;"Release for GNOME 50."&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;159&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The machine runs GNOME 48, GTK 4.18.6 and libadwaita 1.7.6. The pin was &lt;code&gt;v6.5&lt;/code&gt; — GNOME 50, GTK 4.20+. There is a release named for this exact platform, in upstream's own words, and it is two major versions below the pin.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;git log&lt;/code&gt; says the pin was never wrong &lt;em&gt;later&lt;/em&gt;; it was wrong from the start:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git log &lt;span class="nt"&gt;--oneline&lt;/span&gt; &lt;span class="nt"&gt;-L58&lt;/span&gt;,58:flex
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;12a0ddc Add flex: Debian desktop customization to mimic ChromeOS Flex
+ADW_VERSION=${ADW_VERSION:-v6.5}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The newest release at the time, taken as the right one because it was newest, never checked against the desktop it had to run on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Nothing Looked Wrong
&lt;/h2&gt;

&lt;p&gt;A theme silently dropping 159 rule blocks should be visible, and it was not. Two reasons, and the second is the expensive one.&lt;/p&gt;

&lt;p&gt;First, libadwaita applications largely do not use the GTK theme at all — they carry their own stylesheet and follow &lt;code&gt;color-scheme&lt;/code&gt; directly. Most of the desktop stayed dark because most of the desktop was never asking the theme.&lt;/p&gt;

&lt;p&gt;Second, and much worse:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;T&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;~/.local/share/themes
&lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="nv"&gt;$T&lt;/span&gt;/adw-gtk3/gtk-4.0 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; find &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; f | &lt;span class="nb"&gt;sort&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;cmp &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$T&lt;/span&gt;&lt;span class="s2"&gt;/adw-gtk3/gtk-4.0/&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$T&lt;/span&gt;&lt;span class="s2"&gt;/adw-gtk3-dark/gtk-4.0/&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"identical: &lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"DIFFERS:   &lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;identical: ./assets/bullet-symbolic.svg
identical: ./assets/check-symbolic.svg
identical: ./assets/dash-symbolic.svg
identical: ./assets/devel-symbolic.svg
identical: ./gtk.css
identical: ./gtk-dark.css
identical: ./libadwaita.css
identical: ./libadwaita-tweaks.css
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At v6.5, &lt;strong&gt;every one of the eight files under &lt;code&gt;gtk-4.0/&lt;/code&gt; is byte-identical between &lt;code&gt;adw-gtk3&lt;/code&gt; and &lt;code&gt;adw-gtk3-dark&lt;/code&gt;.&lt;/strong&gt; From v6.4 upstream stopped shipping two GTK4 stylesheets. There is one file, and dark is selected at runtime by the &lt;code&gt;@media&lt;/code&gt; query — the query GTK 4.18 throws away.&lt;/p&gt;

&lt;p&gt;So the dark theme's own file defines the light value, unconditionally, and the dark value only inside the block that never executes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;adw-gtk3-dark/gtk-4.0/libadwaita.css:62:
    @define-color window_bg_color #fafafb;      &amp;lt;- light, applied
inside @media (prefers-color-scheme: dark):
    @define-color window_bg_color #222226;      &amp;lt;- dark, discarded
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;46 of the 154 &lt;code&gt;@define-color&lt;/code&gt; rules in that file are inside &lt;code&gt;@media&lt;/code&gt; blocks.&lt;/strong&gt; All 46 were being dropped. And because the two GTK4 trees are identical, &lt;code&gt;set-mode.sh&lt;/code&gt; — the helper this repository installs specifically to flip light and dark — could not change a GTK4 application's palette at all. It flipped a setting between two names that pointed at the same bytes.&lt;/p&gt;

&lt;p&gt;That is the shape of the whole problem: &lt;strong&gt;the setting declared a dark theme, and the filesystem served a light one.&lt;/strong&gt; Nothing in the declaring layer knows what the serving layer did with it, and the only place the two are compared is the log.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix, and the Second Bug It Exposed
&lt;/h2&gt;

&lt;p&gt;Moving the pin to &lt;code&gt;v5.7&lt;/code&gt; is one line. Applying it was not, and the reason is a bug the first bug was hiding.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bash flex theme
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;==&amp;gt; theme: adw-gtk3 v5.7 into /home/xbill/.local/share/themes
    already installed -- skipping
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The stage's idempotence guard tested that the theme &lt;em&gt;directories existed&lt;/em&gt;, not which version was in them. That is fine while the pin is right and fatal once it is wrong: &lt;strong&gt;correcting the pin was a no-op, because the evidence of the bad install was also the thing that suppressed the repair.&lt;/strong&gt; Every re-run confirmed the wrong theme.&lt;/p&gt;

&lt;p&gt;Idempotence has to mean "on the pinned version", not "something is there". The guard now records what it installed and compares:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$THEMES&lt;/span&gt;&lt;span class="s2"&gt;/adw-gtk3-dark"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$THEMES&lt;/span&gt;&lt;span class="s2"&gt;/adw-gtk3"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
   &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="sb"&gt;`&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$THEMES&lt;/span&gt;&lt;span class="s2"&gt;/.adw-gtk3-version"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="sb"&gt;`&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ADW_VERSION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;step &lt;span class="s2"&gt;"already installed at &lt;/span&gt;&lt;span class="nv"&gt;$ADW_VERSION&lt;/span&gt;&lt;span class="s2"&gt; -- skipping"&lt;/span&gt;
  &lt;span class="k"&gt;return
fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also removes the old tree before extracting rather than unpacking over it. The layout changed between the two releases — v6.3 added &lt;code&gt;libadwaita.css&lt;/code&gt; and &lt;code&gt;libadwaita-tweaks.css&lt;/code&gt; to &lt;code&gt;gtk-4.0&lt;/code&gt;, and v5.7 has neither — so extracting v5.7 on top of v6.5 would have left both files orphaned on disk, imported by nothing and still found by anything globbing the directory.&lt;/p&gt;

&lt;p&gt;Re-run, and the two themes are two themes again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;adw-gtk3/gtk-4.0/gtk.css:      @define-color window_bg_color #fafafb;
adw-gtk3-dark/gtk-4.0/gtk.css: @define-color window_bg_color #222226;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;journalctl &lt;span class="nt"&gt;-b&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"Theme parser error"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2,093 to 0&lt;/strong&gt;, and &lt;code&gt;warning&lt;/code&gt;-and-above for the whole system fell from 1,032 to 151. ✅&lt;/p&gt;

&lt;h2&gt;
  
  
  The Same Loop, Three More Times
&lt;/h2&gt;

&lt;p&gt;One defect found this way could be luck. The value is in the loop, so it is worth running it against three other subsystems — each with its own evidence source, and each teaching something different about how to read one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Drivers: the error you must refuse to act on
&lt;/h3&gt;

&lt;p&gt;The kernel log's firmware section looks alarming and mostly is not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;journalctl &lt;span class="nt"&gt;-b&lt;/span&gt; &lt;span class="nt"&gt;-k&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nb"&gt;cat&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; iwlwifi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;iwlwifi 0000:00:14.3: Detected Intel(R) Wi-Fi 6 AX201 160MHz
iwlwifi 0000:00:14.3: firmware: failed to load iwl-debug-yoyo.bin (-2)
iwlwifi 0000:00:14.3: firmware: failed to load iwl-debug-yoyo.bin (-2)
iwlwifi 0000:00:14.3: firmware: failed to load iwl-debug-yoyo.bin (-2)
iwlwifi 0000:00:14.3: loaded firmware version 77.2753b721.0 QuZ-a0-hr-b0-77.ucode
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three failures, then success. &lt;code&gt;iwl-debug-yoyo.bin&lt;/code&gt; is an optional debug blob that Debian does not ship and the driver does not need; the device is working. &lt;strong&gt;An agent that treats "failed" as actionable installs packages to fix a working radio.&lt;/strong&gt; The correction is cheap and general: read the lines &lt;em&gt;after&lt;/em&gt; the error before acting on it, and prefer the state of the device to the wording of the log.&lt;/p&gt;

&lt;p&gt;The genuinely interesting driver finding was on the other side, and it is a non-event worth confirming rather than assuming:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;dkms status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nvidia/610.57.04, 6.12.107+deb13-amd64, x86_64: installed
nvidia/610.57.04, 7.1.8+deb13-amd64, x86_64: installed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This machine runs a &lt;strong&gt;backports kernel&lt;/strong&gt; with an out-of-tree NVIDIA module, which is the configuration most likely to break on an upgrade. DKMS had rebuilt against both kernels, and both header packages are present — so the next kernel will not silently land without a GPU. The taint word says exactly what is loaded and why:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/sys/kernel/tainted     &lt;span class="c"&gt;# 12289 -&amp;gt; bits 0, 12, 13&lt;/span&gt;
mokutil &lt;span class="nt"&gt;--sb-state&lt;/span&gt;               &lt;span class="c"&gt;# SecureBoot disabled&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Proprietary, out-of-tree, unsigned — all three expected here, and all three would be worth an alarm on a machine where Secure Boot was on.&lt;/p&gt;

&lt;h3&gt;
  
  
  Packages: what apt declares against what apt will serve
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;apt&lt;/code&gt; will happily tell you a package is available without telling you it will never choose it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;apt-cache policy | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A1&lt;/span&gt; backports | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; 100 https://deb.debian.org/debian trixie-backports/main amd64 Packages
     release o=Debian Backports,a=stable-backports,n=trixie-backports
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Priority 100 against 500 for everything else.&lt;/strong&gt; Backports is enabled and will never be selected automatically — which is correct, and is also why the running kernel had to be asked for by name. Six third-party origins are configured on this box (Docker, Chrome, cloud-sdk, CUDA, Claude Desktop, and backports), and &lt;code&gt;apt-cache policy&lt;/code&gt; is the only place that relationship is written down.&lt;/p&gt;

&lt;p&gt;The rest of the package state was one line of debris and one pending upgrade:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rc  linux-image-6.12.94+deb13-amd64   (removed, config files remain)
containerd.io  2.3.4 -&amp;gt; 2.3.5  (upgradable)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Neither is a problem. Both are the kind of thing that is invisible until something asks.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tuning: the wait that nothing was waiting for
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;systemd-analyze&lt;/code&gt; is the least-used excellent tool on a Debian desktop, because it does not report which unit is &lt;em&gt;slow&lt;/em&gt; — it reports which unit is &lt;em&gt;in the way&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemd-analyze critical-chain
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;graphical.target @10.626s
└─power-profiles-daemon.service @10.547s +78ms
  └─multi-user.target @10.545s
    └─docker.service @9.507s +1.037s
      └─network-online.target @9.505s
        └─NetworkManager-wait-online.service @1.730s +7.774s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;7.774 seconds, on the path to the desktop appearing, spent blocking until the network was routable&lt;/strong&gt; — because &lt;code&gt;docker.service&lt;/code&gt; asks for &lt;code&gt;network-online.target&lt;/code&gt; and &lt;code&gt;NetworkManager-wait-online&lt;/code&gt; is what satisfies it. Docker does not need a routable address at start; it needs one when a container asks. The dependency is a default, not a decision.&lt;/p&gt;

&lt;p&gt;And one phase up, the loader was carrying a 5-second GRUB menu nobody reads — on a machine whose own repository already ships &lt;code&gt;boot-splash.sh&lt;/code&gt; to remove it, documented as installed-but-never-run.&lt;/p&gt;

&lt;p&gt;Both changed, one reboot, measured:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;phase&lt;/th&gt;
&lt;th&gt;before&lt;/th&gt;
&lt;th&gt;after&lt;/th&gt;
&lt;th&gt;delta&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;firmware&lt;/td&gt;
&lt;td&gt;16.064s&lt;/td&gt;
&lt;td&gt;14.448s&lt;/td&gt;
&lt;td&gt;−1.616s&lt;/td&gt;
&lt;td&gt;&lt;em&gt;not touched&lt;/em&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;loader&lt;/td&gt;
&lt;td&gt;8.014s&lt;/td&gt;
&lt;td&gt;2.776s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−5.238s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;GRUB_TIMEOUT&lt;/code&gt; 5 → 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;kernel&lt;/td&gt;
&lt;td&gt;4.617s&lt;/td&gt;
&lt;td&gt;4.978s&lt;/td&gt;
&lt;td&gt;+0.361s&lt;/td&gt;
&lt;td&gt;&lt;em&gt;not touched&lt;/em&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;userspace&lt;/td&gt;
&lt;td&gt;10.626s&lt;/td&gt;
&lt;td&gt;4.941s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−5.685s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;wait-online&lt;/code&gt; disabled&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;39.324s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;27.144s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−12.180s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The attributable figure is −10.9 s, not −12.180 s&lt;/strong&gt;, and the table says why. Firmware and kernel are phases nothing in this change touches, and they moved by −1.255 s between the two runs on their own. That is the run-to-run noise of the measurement, and it is the reason the two columns cannot simply be subtracted: &lt;em&gt;a change measured against the previous boot is measured against time as well.&lt;/em&gt; One boot either side is a weak design, and the honest claim is a bound — around eleven seconds, of which 5.238 s and 5.685 s land in exactly the two phases that were altered.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;network-online.target&lt;/code&gt; is still reached, incidentally. It is reached at &lt;strong&gt;@1.720 s instead of @9.505 s&lt;/strong&gt;, because nothing is now blocking on it. Docker started clean.&lt;/p&gt;

&lt;h3&gt;
  
  
  And the loop immediately found the next thing
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemd-analyze blame | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3.303s nvidia-persistenced.service
2.689s plymouth-quit-wait.service
2.208s thermald.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;nvidia-persistenced&lt;/code&gt; is now &lt;strong&gt;3.303 s of a 4.941 s userspace boot&lt;/strong&gt; and sits on the critical chain where &lt;code&gt;wait-online&lt;/code&gt; used to. That is not a disappointment; it is the loop working. Removing the largest cost promotes the next one into view, and it was invisible while a 7.8-second wait was in front of it. ✅&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the Agent Actually Fits
&lt;/h2&gt;

&lt;p&gt;Nothing above required a capability Debian was missing. Every number came from &lt;code&gt;journalctl&lt;/code&gt;, &lt;code&gt;systemd-analyze&lt;/code&gt;, &lt;code&gt;dpkg&lt;/code&gt;, &lt;code&gt;dkms&lt;/code&gt;, &lt;code&gt;apt-cache&lt;/code&gt; and &lt;code&gt;cmp&lt;/code&gt; — all of them installed, all of them documented, none of them new. The evidence had been there, in the same files, since the machine was configured.&lt;/p&gt;

&lt;p&gt;What was missing was the other half of the loop: &lt;strong&gt;something willing to read a thousand warnings, at the moment they stop being free to ignore.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is a specific job, and it is worth being precise about which parts of it are the agent's:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reading at volume without triage fatigue.&lt;/strong&gt; 1,032 warnings is where a human
starts sampling. The 805-line defect was in the histogram's first row, and the
histogram is trivial to build — the reason nobody had built it is that nobody
had a reason to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Correlating across sources that share no vocabulary.&lt;/strong&gt; The finding needed a
journal message, a CSS file, a &lt;code&gt;dpkg -l&lt;/code&gt; version, four GitHub release notes and
a &lt;code&gt;git log -L&lt;/code&gt; on one line of a shell script. Each is unremarkable alone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Acting, then re-reading.&lt;/strong&gt; The fix is only confirmed by the same query that
found it returning 0 — and by &lt;code&gt;warning+&lt;/code&gt; dropping 1,032 → 151, which is a
different check than "the desktop still looks fine".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the parts that are not:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deciding what is worth fixing.&lt;/strong&gt; The &lt;code&gt;iwlwifi&lt;/code&gt; line is a failure the correct
action is to ignore. An agent that treats every &lt;code&gt;error&lt;/code&gt; as work will find
plenty of it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anything that touches the boot chain.&lt;/strong&gt; &lt;code&gt;boot-splash.sh&lt;/code&gt; is in this
repository precisely because it edits GRUB, and this repository deliberately
never runs it for you. Reading it first was a human decision; so was accepting
that Docker would now start without waiting for the network.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Believing the log's own metadata.&lt;/strong&gt; &lt;code&gt;_COMM&lt;/code&gt; said &lt;code&gt;cat&lt;/code&gt; 1,288 times.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The loop that produced everything above is four steps and does not need tooling: &lt;strong&gt;read the evidence, form a claim about which layer disagrees with which, change one thing, re-read the same source.&lt;/strong&gt; The last step is the one that distinguishes it from a guess, and it is the step a settings panel cannot offer, because a settings panel only ever shows you what was declared.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cheat Sheet
&lt;/h2&gt;

&lt;p&gt;Nothing here writes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 0. can you actually see the system's evidence, or only your own?&lt;/span&gt;
&lt;span class="nb"&gt;id&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'adm\|systemd-journal'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"unprivileged view -- use sudo"&lt;/span&gt;

&lt;span class="c"&gt;# 1. volume first, at warning, not just err&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;journalctl &lt;span class="nt"&gt;-b&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; err     &lt;span class="nt"&gt;--no-pager&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;journalctl &lt;span class="nt"&gt;-b&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; warning &lt;span class="nt"&gt;--no-pager&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;

&lt;span class="c"&gt;# 2. collapse a thousand lines into distinct problems&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;journalctl &lt;span class="nt"&gt;-b&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; warning &lt;span class="nt"&gt;--no-pager&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s/[0-9a-f]\{8,\}/HEX/g; s/[0-9]\+/N/g'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt;

&lt;span class="c"&gt;# 3. attribute by payload, not by _COMM&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;journalctl &lt;span class="nt"&gt;-b&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nb"&gt;cat&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;pattern&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'s/^\(([a-zA-Z0-9_.-]+):[0-9]+\).*/\1/'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt;

&lt;span class="c"&gt;# 4. what is in the way of the desktop -- not what is slow&lt;/span&gt;
systemd-analyze
systemd-analyze critical-chain
systemd-analyze blame | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-10&lt;/span&gt;

&lt;span class="c"&gt;# 5. out-of-tree modules against every installed kernel&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;dkms status &lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;cat&lt;/span&gt; /proc/sys/kernel/tainted &lt;span class="p"&gt;;&lt;/span&gt; mokutil &lt;span class="nt"&gt;--sb-state&lt;/span&gt;

&lt;span class="c"&gt;# 6. what apt will actually serve you, priorities included&lt;/span&gt;
apt-cache policy &lt;span class="p"&gt;;&lt;/span&gt; apt list &lt;span class="nt"&gt;--upgradable&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="p"&gt;;&lt;/span&gt; dpkg &lt;span class="nt"&gt;-l&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'^rc'&lt;/span&gt;

&lt;span class="c"&gt;# 7. re-read the exact query that found it -- this is the step that confirms&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The goal of this article was to find out what a correctly-behaving Debian desktop was failing to mention, and whether reading its own logs systematically would repay the effort. The key to the solution was treating the configuration layer and the serving layer as two separate claims that have to be compared — what &lt;code&gt;gsettings&lt;/code&gt; declared against what GTK parsed, what a unit file requested against what the boot actually waited for — because every defect found was invisible in the declaring layer and plainly stated in the serving one. The measured results were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;2,093 CSS parse errors per boot went to 0.&lt;/strong&gt; The theme pin was two major
versions above what the desktop's GTK could parse, from the first commit that
introduced it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;System-wide &lt;code&gt;warning&lt;/code&gt;-and-above fell 1,032 → 151.&lt;/strong&gt; &lt;code&gt;err&lt;/code&gt;-and-above stayed at
17, correctly — none of those were theme-related.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;46 of 154 &lt;code&gt;@define-color&lt;/code&gt; rules were being discarded&lt;/strong&gt;, and all eight
&lt;code&gt;gtk-4.0&lt;/code&gt; files were byte-identical between the light and dark themes, so the
mode switcher could not change a GTK4 application at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Boot went 39.324 s → 27.144 s, of which −10.9 s is attributable&lt;/strong&gt; — 5.238 s
of GRUB timeout and 5.685 s of &lt;code&gt;NetworkManager-wait-online&lt;/code&gt; — with the
untouched phases drifting −1.255 s as noise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;network-online.target&lt;/code&gt; is now reached at @1.720 s rather than @9.505 s&lt;/strong&gt;, and
Docker starts clean without it being waited on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A second bug was hidden by the first&lt;/strong&gt;: the theme stage's idempotence guard
tested only that files existed, so correcting the pin was a no-op until the
guard was made version-aware.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scope: one laptop, one operating system, measured 2026-09-08 on Debian 13 with GNOME 48, GTK 4.18.6 and kernel 7.1.8+deb13-amd64 from backports. The boot figures are &lt;strong&gt;n = 1 on each side&lt;/strong&gt; — a single boot before and a single boot after — which is why the attributable claim is stated as a bound rather than a measurement, and the ±1.255 s drift in the two untouched phases is the only estimate of noise available from that design; repeated boots on each configuration are queued and would replace it. The 2,093 → 0 result is not subject to the same weakness, being a count of a deterministic parse failure rather than a timing. Whether any application's appearance visibly changed as a result of the theme fix was &lt;strong&gt;not&lt;/strong&gt; measured: the five confirmed emitters prove the file was parsed, not that a user could see the difference, and libadwaita applications never consulted the theme either way. &lt;code&gt;nvidia-persistenced&lt;/code&gt; at 3.303 s is now the largest item on the critical chain and has not been investigated.&lt;/p&gt;

&lt;p&gt;The strategy of reading the system's own evidence at volume, attributing it by payload rather than metadata, changing one thing, and re-running the query that found it was validated with an incremental step by step approach.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: I ran this with Claude Code as the diagnostic agent — it swept the journal, built the histograms, traced the pin to the upstream release notes, wrote the fix and re-measured after the reboot, while I steered and made the calls about what to touch. The commands, the counts and both defects are real and archived in the repository.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>debian</category>
      <category>linux</category>
      <category>claudecode</category>
      <category>devops</category>
    </item>
    <item>
      <title>The Cable Buys Headroom: 91% of a USB 2.0 Bus, 3.6% of a Thunderbolt One</title>
      <dc:creator>xbill</dc:creator>
      <pubDate>Tue, 08 Sep 2026 16:09:41 +0000</pubDate>
      <link>https://dev.to/gde/the-cable-buys-headroom-91-of-a-usb-20-bus-36-of-a-thunderbolt-one-563n</link>
      <guid>https://dev.to/gde/the-cable-buys-headroom-91-of-a-usb-20-bus-36-of-a-thunderbolt-one-563n</guid>
      <description>&lt;p&gt;This article walks through diagnosing a USB tether that would not negotiate above USB 2.0 speed, on a phone that advertises 10 Gb/s. The hunt took four cables and three physical ports across two days.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;USB link speed is autonegotiated between the host, the cable and the device, and the result is the slowest mode all three can manage.&lt;/strong&gt; The cable is a full participant in that negotiation, a USB 2.0 cable has no way to vote for anything above 480 Mbps, and &lt;strong&gt;nothing anywhere reports that it happened.&lt;/strong&gt; The tether comes up, DHCP works, traffic flows, and the link is capped at a fifth of what both ends were offering.&lt;/p&gt;

&lt;p&gt;What the right cable buys is not speed. It is &lt;strong&gt;headroom&lt;/strong&gt;, and the difference is not subtle: the best USB 2.0 pass in this log used &lt;strong&gt;91% of what that bus can usably carry&lt;/strong&gt;, while the same link on a SuperSpeed cable uses &lt;strong&gt;3.6%&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Every command below is one you can run against your own tether, and every number comes from a record in the repository:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/xbill9/tether" rel="noopener noreferrer"&gt;https://github.com/xbill9/tether&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Symptom
&lt;/h2&gt;

&lt;p&gt;The repository holds 45 measurement records across 14 handsets from four vendors, each one a full pass — three sequential single-stream transfers, a four-stream parallel test, and a ping — against a fixed endpoint and a fixed 8 MB transfer size.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;41 of those 45 records enumerated at 480 Mbps.&lt;/strong&gt; Fourteen handsets, three drivers, two carriers, three physical receptacles, and for two days not one USB 3.0 negotiation anywhere.&lt;/p&gt;

&lt;p&gt;The most conspicuous offender was an iPhone 17 Pro, whose BOS descriptor is not shy about what it can do:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bcdUSB               2.10
SuperSpeed USB Device Capability:
  wSpeedsSupported   0x000e     -&amp;gt; Full, High, SuperSpeed (5Gbps)
SuperSpeedPlus USB Device Capability:
  bmSublinkSpeedAttr[0]  10Gb/s Symmetric RX SuperSpeedPlus
  bmSublinkSpeedAttr[1]  10Gb/s Symmetric TX SuperSpeedPlus
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A device declaring 10 Gb/s, enumerating at 480 Mbps. That is a factor of about twenty-one, and it is the kind of gap that makes you stop measuring throughput and start reading &lt;code&gt;sysfs&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bus Rate Is Negotiated, and Every Link in the Chain Votes
&lt;/h2&gt;

&lt;p&gt;USB link speed is not a property of the phone, and it is not a property of the laptop. It is negotiated at every attach between three parties — host controller, cable, device — and &lt;strong&gt;the trained rate is the fastest mode all three can do.&lt;/strong&gt; One USB 2.0 component anywhere in that chain and the entire link comes up at 480 Mbps.&lt;/p&gt;

&lt;p&gt;The cable is a real participant in that vote, not a passive wire. SuperSpeed rides on two extra differential pairs that a USB 2.0 cable does not physically contain — it carries &lt;code&gt;D+&lt;/code&gt;/&lt;code&gt;D-&lt;/code&gt; and nothing else. Put one between a 10 Gb/s phone and a 10 Gb/s port and the two ends will negotiate down to High Speed and work perfectly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And nothing tells you.&lt;/strong&gt; No error, no &lt;code&gt;dmesg&lt;/code&gt; warning, no degraded-mode indicator, no line anywhere saying a cable capped you. The only evidence is a number in &lt;code&gt;sysfs&lt;/code&gt; that you have to go and read, next to a second number saying what the device had offered:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Reading&lt;/th&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;speed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/sys/bus/usb/devices/&amp;lt;dev&amp;gt;/speed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;what the three parties &lt;strong&gt;agreed on&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BOS descriptor&lt;/td&gt;
&lt;td&gt;&lt;code&gt;lsusb -v -d &amp;lt;vid&amp;gt;:&amp;lt;pid&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;what the device &lt;strong&gt;offered&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The gap between those two numbers is the cable's vote.&lt;/strong&gt; Steps 1 and 2 below are those two readings, and everything after them is narrowing down which participant cast the low one.&lt;/p&gt;

&lt;h2&gt;
  
  
  At This Point You Should Have
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A phone tethered over USB and holding the default route — check this first, or
you will measure Wi-Fi&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;lsusb&lt;/code&gt; from &lt;code&gt;usbutils&lt;/code&gt;, and &lt;code&gt;ip&lt;/code&gt; from &lt;code&gt;iproute2&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Read access to &lt;code&gt;/sys/bus/usb/devices&lt;/code&gt; and &lt;code&gt;/sys/class/net&lt;/code&gt;, which needs no root&lt;/li&gt;
&lt;li&gt;Every other route-capable interface &lt;strong&gt;down&lt;/strong&gt;. If Wi-Fi is up, &lt;code&gt;curl&lt;/code&gt; will
succeed over it and the result will look like a tethering measurement&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of the diagnosis below transfers a single byte over the cellular link. That matters here: a full measurement pass in this repository costs roughly 56 MB of metered data, and everything in Steps 1 through 4 is free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — Read the Bus Speed First
&lt;/h2&gt;

&lt;p&gt;This is the one reading that invalidates everything downstream if you skip it. A charge-only or USB 2.0 cable silently caps the bus at 480 Mbps and nothing anywhere reports an error.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# find the tether interface and its USB device&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; /sys/class/net/&lt;span class="k"&gt;*&lt;/span&gt;/device/driver&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;ifn&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;basename&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;dirname&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;dirname&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="si"&gt;)))&lt;/span&gt;
  &lt;span class="nv"&gt;drv&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;basename&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;readlink&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="si"&gt;))&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nv"&gt;$drv&lt;/span&gt; &lt;span class="k"&gt;in &lt;/span&gt;cdc_ncm|rndis_host|ipheth&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ifn&lt;/span&gt;&lt;span class="s2"&gt;  &lt;/span&gt;&lt;span class="nv"&gt;$drv&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;;&lt;/span&gt; &lt;span class="k"&gt;esac&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;enxb65575abcda3  ipheth
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then walk from the interface back to the USB device and read its trained speed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;dev&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;basename&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;readlink&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /sys/class/net/enxb65575abcda3/device/..&lt;span class="si"&gt;))&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/bus/usb/devices/&lt;span class="nv"&gt;$dev&lt;/span&gt;/speed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;480
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;480&lt;/code&gt; is USB 2.0, &lt;code&gt;5000&lt;/code&gt; is USB 3.0 Gen 1, &lt;code&gt;10000&lt;/code&gt; is Gen 2. Alongside it, &lt;code&gt;/sys/class/net/&amp;lt;if&amp;gt;/speed&lt;/code&gt; gives the negotiated link rate — though note that &lt;code&gt;ipheth&lt;/code&gt; returns &lt;code&gt;EINVAL&lt;/code&gt; there and reports nothing, so on an iPhone this field stays blank in every record.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — Ask the Device What It Is Capable Of
&lt;/h2&gt;

&lt;p&gt;A 480 reading only means something once you know what the device brought to the negotiation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;lsusb &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; 05ac:12a8 2&amp;gt;/dev/null | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A3&lt;/span&gt; &lt;span class="s2"&gt;"SuperSpeed"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The descriptor block at the top of this article is that output. &lt;strong&gt;The device capability is the control.&lt;/strong&gt; Compare the iPhone 16e measured eight minutes before the fastest pass in this log: it advertises no SuperSpeed capability at all, so its 480 is its own ceiling and no cable on earth will move it.&lt;/p&gt;

&lt;p&gt;That single distinction separates "something in the chain voted 480" from "the phone itself voted 480", and it is free to read. Where the device offers SuperSpeed and the link trained at High Speed, one of the other two participants is responsible — and there are only two of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — Find Every Port, Not Just the Pretty Ones
&lt;/h2&gt;

&lt;p&gt;This step exists because the investigation got it wrong, in print, twice.&lt;/p&gt;

&lt;p&gt;The obvious move on a modern laptop is to enumerate the Type-C connectors, since the kernel binds each one to its USB ports at boot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dmesg | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"typec port"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;typec port0: bound usb3-port1 (ops connector_ops [usbcore])
typec port0: bound usb4-port1 (ops connector_ops [usbcore])
typec port1: bound usb3-port2 (ops connector_ops [usbcore])
typec port1: bound usb2-port1 (ops connector_ops [usbcore])
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read against &lt;code&gt;lspci&lt;/code&gt;, that is a complete map of the machine's USB-C:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type-C connector&lt;/th&gt;
&lt;th&gt;USB 2.0 half&lt;/th&gt;
&lt;th&gt;SuperSpeed half&lt;/th&gt;
&lt;th&gt;Controller&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;port0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;3-1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;4-1&lt;/code&gt; (10 Gb/s)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;00:14.0&lt;/code&gt; Alder Lake PCH&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;port1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;3-2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;2-1&lt;/code&gt; (20 Gb/s)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;00:0d.0&lt;/code&gt; Thunderbolt 4&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That table is correct. The conclusion drawn from it — &lt;em&gt;"there is no untried port on this machine"&lt;/em&gt; — was wrong, and it cost about an hour.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;/sys/class/typec&lt;/code&gt; enumerates Type-C connectors only.&lt;/strong&gt; A Type-A receptacle has no Power Delivery and therefore no connector object, so it cannot appear there. The machine had a third external receptacle the map could not see.&lt;/p&gt;

&lt;p&gt;Take the port inventory from the hotplug entries in the port list instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;p &lt;span class="k"&gt;in&lt;/span&gt; /sys/bus/usb/devices/usb&lt;span class="k"&gt;*&lt;/span&gt;/&lt;span class="k"&gt;*&lt;/span&gt;/usb&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="nt"&gt;-port&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="nv"&gt;$p&lt;/span&gt;/connect_type 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"hotplug"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;continue
  &lt;/span&gt;&lt;span class="nv"&gt;peer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"-"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="s2"&gt;/peer"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;peer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;basename&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;readlink&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="s2"&gt;/peer"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;basename&lt;/span&gt; &lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;  peer=&lt;/span&gt;&lt;span class="nv"&gt;$peer&lt;/span&gt;&lt;span class="s2"&gt;  location=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="nv"&gt;$p&lt;/span&gt;/location&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;usb3-port3  peer=usb4-port2  location=0x80000301
usb4-port2  peer=usb3-port3  location=0x80000301
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;usb3-port3&lt;/code&gt; is the USB-A receptacle. Its &lt;code&gt;peer&lt;/code&gt; is a 10 Gb/s port on another hub, and the shared &lt;code&gt;location&lt;/code&gt; is the kernel saying &lt;em&gt;these two entries are two halves of one physical hole in the side of the laptop&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That peer relationship is the single most useful line in this whole investigation&lt;/strong&gt;, for a reason that only becomes clear in a moment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — Ask the Cable Directly, and Watch It Refuse
&lt;/h2&gt;

&lt;p&gt;An electronically marked USB-C cable declares its own data capability over Power Delivery, and Linux exposes that as a &lt;code&gt;portN-cable&lt;/code&gt; device with an &lt;code&gt;identity/&lt;/code&gt; directory of PD VDOs. The USB-C specification &lt;strong&gt;requires&lt;/strong&gt; an e-marker on a passive cable supporting more than USB 2.0, and does not require one on a USB 2.0 cable. So a missing e-marker would be real evidence.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; /sys/class/typec/
&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/class/typec/port0-partner/usb_power_delivery_revision
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;port0  port0-partner  port1  usb_power_delivery_revision
0.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;There is no cable node, and that is not evidence about the cable.&lt;/strong&gt; The stack is not surfacing VDO data at all: the connected partner — the phone itself — has an empty &lt;code&gt;identity/&lt;/code&gt; directory and reports PD revision &lt;code&gt;0.0&lt;/code&gt;. A stack that will not show the phone's PD identity was never going to show the cable's.&lt;/p&gt;

&lt;p&gt;Worth knowing precisely because it looks like it should work. On a host that does report cable identity this is the whole answer in one &lt;code&gt;cat&lt;/code&gt;. Here it is silence, not a finding, and treating it as a finding would have convicted the cable on no evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Hunt
&lt;/h2&gt;

&lt;p&gt;With the cable unreadable, the only remaining instrument is substitution. Four cables, three receptacles, over one afternoon:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pass&lt;/th&gt;
&lt;th&gt;Cable&lt;/th&gt;
&lt;th&gt;Receptacle&lt;/th&gt;
&lt;th&gt;Bus&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;C-to-C, unmarked&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;3-2&lt;/code&gt; (Thunderbolt 4 connector)&lt;/td&gt;
&lt;td&gt;480&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1b&lt;/td&gt;
&lt;td&gt;same cable&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;3-1&lt;/code&gt; (the other Type-C)&lt;/td&gt;
&lt;td&gt;480&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;C-to-C, second cable&lt;/td&gt;
&lt;td&gt;&lt;code&gt;3-1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;480&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;C-to-C, third cable&lt;/td&gt;
&lt;td&gt;&lt;code&gt;3-1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;480&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;A-to-C, unmarked&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;3-3&lt;/code&gt; (USB-A)&lt;/td&gt;
&lt;td&gt;480&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Zero USB errors and zero drops in every pass. The host has two SuperSpeed root hubs, both enumerated, both idle, and neither had ever seen a device.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The tempting reading is that four negatives is mounting evidence against the host. It is not.&lt;/strong&gt; None of those four cables was &lt;em&gt;known&lt;/em&gt; to carry SuperSpeed pairs. Unmarked C-to-C cables — the kind that ship in the box with a phone or a charger — are overwhelmingly USB 2.0 by construction. Four of them all being USB 2.0 is not a coincidence in need of explanation; it is the expected outcome.&lt;/p&gt;

&lt;p&gt;Four unknowns is close to one result repeated, not four independent tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Trap in the Middle
&lt;/h2&gt;

&lt;p&gt;Here is the part of the investigation most worth stealing, because it has nothing to do with USB.&lt;/p&gt;

&lt;p&gt;Throughput was measured on every one of those passes, and it moved a great deal:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;12:40 cable 1&lt;/th&gt;
&lt;th&gt;13:15 cable 2&lt;/th&gt;
&lt;th&gt;13:19 cable 2, settled&lt;/th&gt;
&lt;th&gt;13:26 cable 3&lt;/th&gt;
&lt;th&gt;14:07 cable 4&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Single (Mbps)&lt;/td&gt;
&lt;td&gt;96 / 103 / 103&lt;/td&gt;
&lt;td&gt;56 / 36 / 21&lt;/td&gt;
&lt;td&gt;64 / 57 / 59&lt;/td&gt;
&lt;td&gt;55 / 25 / 68&lt;/td&gt;
&lt;td&gt;76 / 58 / 61&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4-stream aggregate&lt;/td&gt;
&lt;td&gt;178&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;73&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;85&lt;/td&gt;
&lt;td&gt;101&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;130&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RTT avg&lt;/td&gt;
&lt;td&gt;38.0 ms&lt;/td&gt;
&lt;td&gt;103.6 ms&lt;/td&gt;
&lt;td&gt;54.2 ms&lt;/td&gt;
&lt;td&gt;45.9 ms&lt;/td&gt;
&lt;td&gt;52.1 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RTT mdev&lt;/td&gt;
&lt;td&gt;6.7 ms&lt;/td&gt;
&lt;td&gt;110.3 ms&lt;/td&gt;
&lt;td&gt;30.9 ms&lt;/td&gt;
&lt;td&gt;9.3 ms&lt;/td&gt;
&lt;td&gt;12.1 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Aggregate climbed 73 → 85 → 101 → 130 across four consecutive cable swaps. Every new cable looked better than the one before it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;None of that is a cable effect.&lt;/strong&gt; The carrier was recovering from a dip across the afternoon, and each swap happened to coincide with a step of that recovery. Time is the variable that moved; the cable is the one that did not. The USB errors were zero throughout, and a cable degrading throughput by 60% would show up in those counters.&lt;/p&gt;

&lt;p&gt;Two rules came out of this and both are now in the repository's methodology:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Record three single-stream runs, never one.&lt;/strong&gt; One phone in this log produced
15, 44 and 116 Mbps on three consecutive identical transfers. Any single number
is a lie about the link.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A/B a hardware change against time, not against the previous hour.&lt;/strong&gt; If you
cannot interleave, do not attribute.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Cable That Worked
&lt;/h2&gt;

&lt;p&gt;The next day, a new USB-A to USB-C cable — around $15, bought specifically to be a &lt;em&gt;known&lt;/em&gt; SuperSpeed cable rather than another unknown.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;480  -&amp;gt;  5000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The iPhone trained SuperSpeed and moved to a different hub in &lt;code&gt;sysfs&lt;/code&gt;, appearing at &lt;code&gt;4-2&lt;/code&gt; rather than the &lt;code&gt;3-x&lt;/code&gt; of every previous pass. That looks like a port change and it is not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;usb4-port2  peer=usb3-port3  location=0x80000301
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;4-2&lt;/code&gt; is &lt;code&gt;usb4-port2&lt;/code&gt;, whose peer is &lt;code&gt;usb3-port3&lt;/code&gt; — the same USB-A receptacle tested at 480 the day before, same location code. &lt;strong&gt;The device moved hubs only because it started training SuperSpeed, which is exactly what a successful 5 Gb/s negotiation looks like on this host.&lt;/strong&gt; The receptacle is a control here. The cable is the only variable that moved.&lt;/p&gt;

&lt;p&gt;A Pixel 9a on the same new cable, same receptacle, same DHCP lease, confirmed it from the other side:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;2026-09-05, old cable&lt;/th&gt;
&lt;th&gt;2026-09-06, $15 cable&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Bus speed&lt;/td&gt;
&lt;td&gt;480 Mbps&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5000 Mbps&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Negotiated link&lt;/td&gt;
&lt;td&gt;425 Mbps&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3750 Mbps&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two handsets, one cable swap, and the question that had eaten an afternoon was answered. The unmarked cables were USB 2.0 cables. ✅&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Cable Unlocked: Headroom
&lt;/h2&gt;

&lt;p&gt;The bus reading is the headline. &lt;strong&gt;Headroom is what it actually buys&lt;/strong&gt;, and it is the number that says whether any of this was worth $15.&lt;/p&gt;

&lt;p&gt;The repository's diagnostic constant for the realistic bulk throughput of a 480 Mbps USB 2.0 bus, after protocol overhead, is &lt;strong&gt;300 Mbps&lt;/strong&gt;. Measure every pass in the log as a share of the bus it ran on and the picture is stark:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pass&lt;/th&gt;
&lt;th&gt;Bus&lt;/th&gt;
&lt;th&gt;4-stream aggregate&lt;/th&gt;
&lt;th&gt;Share of the bus&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;🥉 Best USB 2.0 pass in the log&lt;/td&gt;
&lt;td&gt;480&lt;/td&gt;
&lt;td&gt;273 Mbps&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;56.9%&lt;/strong&gt; of raw — and &lt;strong&gt;91% of usable&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🥈 Anker USB-A to USB-C, BBR&lt;/td&gt;
&lt;td&gt;5000&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;365.185 Mbps&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;7.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🥈 Same receptacle, CUBIC&lt;/td&gt;
&lt;td&gt;5000&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;370.695 Mbps&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;7.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🥇 Thunderbolt cable&lt;/td&gt;
&lt;td&gt;10000&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;361.330 Mbps&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.6%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🥇 Thunderbolt, BBR&lt;/td&gt;
&lt;td&gt;10000&lt;/td&gt;
&lt;td&gt;317.250 Mbps&lt;/td&gt;
&lt;td&gt;3.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;91% against 3.6% is the whole argument&lt;/strong&gt;, and the two denominators are worth naming so the comparison is read correctly. The 91% is 273 Mbps against the &lt;strong&gt;usable&lt;/strong&gt; 300 Mbps of a 480 Mbps bus; the 3.6% is 361.330 against the &lt;strong&gt;raw&lt;/strong&gt; 10000, because this repository has no measured usable ceiling for a SuperSpeed bus and inventing one would be worse than the mismatch. Compared raw to raw it is &lt;strong&gt;56.9% against 3.6%&lt;/strong&gt; — a factor of sixteen rather than twenty-five, and the same conclusion either way.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Share of the bus is arithmetic throughout — each record's 4-stream aggregate over its own &lt;code&gt;bus_speed_mbps&lt;/code&gt;, so 273/480 = 56.9% and 365.185/5000 = 7.3%. The 91% figure is 273 against the 300 Mbps usable ceiling rather than the raw 480.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On USB 2.0 the best pass in 45 records was at 91% of what the bus can usably carry.&lt;/strong&gt; There was nothing left. Every good pass in the 480 era was pressed up against a ceiling, and the ones that were not were being held down by the carrier instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On SuperSpeed the same link uses about 7% of the bus.&lt;/strong&gt; The cable did not make anything faster by itself; it moved the ceiling from &lt;em&gt;just above the traffic&lt;/em&gt; to &lt;em&gt;fourteen times above the traffic&lt;/em&gt;, so the tether is now free to take whatever the radio gives it on any given evening — and this link swings by more than 4x on its own within minutes.&lt;/p&gt;

&lt;p&gt;Four of those measurements also clear the 300 Mbps line outright:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measurement&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;th&gt;Above 300 Mbps?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Best 480-era aggregate, 45 records&lt;/td&gt;
&lt;td&gt;273 Mbps&lt;/td&gt;
&lt;td&gt;no — 91% of usable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4-stream aggregate, USB-C receptacle&lt;/td&gt;
&lt;td&gt;370.695 Mbps&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4-stream aggregate, Anker cable under BBR&lt;/td&gt;
&lt;td&gt;365.185 Mbps&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4-stream aggregate, Thunderbolt&lt;/td&gt;
&lt;td&gt;361.330 Mbps&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Single 32 MB transfer, USB-C receptacle&lt;/td&gt;
&lt;td&gt;351.1 Mbps&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Those numbers were not reachable on a USB 2.0 bus.&lt;/strong&gt; Not "would have been slower" — not reachable. Every one of them needed a cable that could vote for SuperSpeed. ✅&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Aggregate Is the Test
&lt;/h2&gt;

&lt;p&gt;The four-stream number is doing the work in both tables above, and it is worth saying why rather than treating it as a second opinion on the single-stream figure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Single-stream against four-stream aggregate is the one comparison that separates "the carrier is slow" from "something on this machine is collapsing."&lt;/strong&gt; If four flows together go much faster than one, the wide-area path has headroom and the problem is local. If they do not, there is no headroom to find.&lt;/p&gt;

&lt;p&gt;The Anker cable's BBR pass is a clean worked example. Single-stream ran 134.930 / 113.576 / 123.229 Mbps — a 1.19x spread — against a four-stream aggregate of &lt;strong&gt;365.185 Mbps, 2.95x the single-stream mean.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Read those two numbers together and the link describes itself. Four flows found nearly three times what one flow did, so the wide-area path plainly had that much to give and nothing local was collapsing: a 1.19x spread with zero errors and zero drops leaves no radio loss for congestion control to misread. &lt;strong&gt;The ceiling in that reading is not on this machine&lt;/strong&gt;, which is exactly the state you want to be in — the tether is out of the way and the only thing left is the radio.&lt;/p&gt;

&lt;p&gt;The reverse reading is the one that catches people. Where the aggregate lands on top of the single-stream figure, four flows found nothing extra, and no amount of tuning on the host will produce headroom that is not there.&lt;/p&gt;

&lt;p&gt;Read the aggregate before you attribute anything to hardware. It is the reading that tells you whether the answer is even on your side of the link.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Link Reached
&lt;/h2&gt;

&lt;p&gt;Once the bus was out of the way, three passes on this handset across two evenings, on two cables and two host controllers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;🥇 Thunderbolt, CUBIC&lt;/th&gt;
&lt;th&gt;🥈 USB-C receptacle, CUBIC&lt;/th&gt;
&lt;th&gt;🥉 Anker cable, BBR&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Bus speed&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10000 Mbps&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;5000 Mbps&lt;/td&gt;
&lt;td&gt;5000 Mbps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Single stream (Mbps)&lt;/td&gt;
&lt;td&gt;151.036 / 119.931 / 147.540&lt;/td&gt;
&lt;td&gt;134.856 / 143.749 / 129.250&lt;/td&gt;
&lt;td&gt;134.930 / 113.576 / 123.229&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4-stream aggregate&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;361.330&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;370.695&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;365.185&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RTT avg&lt;/td&gt;
&lt;td&gt;27.268 ms&lt;/td&gt;
&lt;td&gt;29.601 ms&lt;/td&gt;
&lt;td&gt;36.715 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RTT mdev&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.272 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;7.171 ms&lt;/td&gt;
&lt;td&gt;5.597 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Three aggregates within 3% of each other — 361.330, 370.695 and 365.185 — across two different cables, two host controllers, two congestion control algorithms and a bus that differs by 2x.&lt;/strong&gt; None of those variables moved the number. The link settles around 365 Mbps and the hardware underneath it has stopped mattering, which is what having headroom looks like.&lt;/p&gt;

&lt;p&gt;The RTT came with it: 27.268 ms average with &lt;code&gt;mdev&lt;/code&gt; of &lt;strong&gt;2.272 ms&lt;/strong&gt; on the Thunderbolt pass, the flattest jitter anywhere in the log.&lt;/p&gt;

&lt;p&gt;The BBR column is worth its place for a second reason. Every early SuperSpeed pass ran &lt;code&gt;cubic&lt;/code&gt; where the 480-era records ran &lt;code&gt;bbr&lt;/code&gt;, so bus and congestion control had moved together and neither could be credited. Matched on the same port and bus, BBR gives 365.185 against CUBIC's 370.695 — within a few percent, with &lt;code&gt;mdev&lt;/code&gt; slightly lower. &lt;strong&gt;The bus is what these figures rest on.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Buy Headroom Once, and Stop
&lt;/h2&gt;

&lt;p&gt;Note the third column of that table: moving from the Thunderbolt port back to a plain USB-C receptacle &lt;strong&gt;halved the bus and cost nothing&lt;/strong&gt; — 370.695 Mbps against 361.330. At 361 Mbps the phone is using about 3.6% of a 10000 Mbps bus, by arithmetic, against 7.4% of a 5000 Mbps one. Both are miles of headroom. Doubling an already-idle ceiling buys exactly nothing, and Gen 2 is not worth chasing on a tether.&lt;/p&gt;

&lt;p&gt;So the rule is narrow and cheap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✅ &lt;strong&gt;One known-good cable, then stop.&lt;/strong&gt; Four unmarked cables cast four identical
480 votes. One rated cable produced 5000 on two different handsets, on the same
receptacle, with nothing else changed.&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;Read the bus before you tune anything.&lt;/strong&gt; &lt;code&gt;cat /sys/bus/usb/devices/&amp;lt;dev&amp;gt;/speed&lt;/code&gt;
is free, and a 480 reading on a SuperSpeed-capable phone caps you at roughly
300 Mbps of real throughput no matter what else you fix.&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;Do not buy ceiling you cannot reach.&lt;/strong&gt; 5000 and 10000 measured the same on
this link, repeatedly. Headroom is binary in practice: you either have enough or
you do not.&lt;/li&gt;
&lt;li&gt;❌ &lt;strong&gt;Do not trust the connector to tell you.&lt;/strong&gt; Two of the four failing cables were
USB-C on both ends, in a Thunderbolt 4 port, on a phone advertising 10 Gb/s.
Every visible part of that chain looked right.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;One limit on all of this, and it is being closed by measurement rather than argument.&lt;/strong&gt; The 300 Mbps figure is the repository's diagnostic constant, so the claim above is a bound argument rather than a controlled A/B — no pass has yet put the fast link back onto a USB 2.0 cable to measure what it would cost. That controlled re-test is queued, and it is the number this article most wants.&lt;/p&gt;

&lt;h2&gt;
  
  
  One More Trap: the Transfer Size
&lt;/h2&gt;

&lt;p&gt;While measuring the fast link, the single-stream figure sat near 140 Mbps against a four-stream aggregate of 361 — the classic shape of per-flow carrier shaping, and that is what it was written up as.&lt;/p&gt;

&lt;p&gt;It was wrong, and the correction is worth the paragraph.&lt;/p&gt;

&lt;p&gt;A single &lt;strong&gt;32 MB&lt;/strong&gt; transfer on the same connection ran 332.3 Mbps — essentially the whole four-stream aggregate, from one flow. So the gap was about the fixed 8 MB transfer size, not the link. Except the first attempt to prove that was also uncontrolled: a fitted model predicted 267 Mbps at 20 MB and the measurement came back at &lt;strong&gt;73.0 Mbps&lt;/strong&gt;, because this link can collapse by 4.8x on its own between one transfer and the next.&lt;/p&gt;

&lt;p&gt;The design that settled it was six transfers alternating 8 and 32 MB, &lt;strong&gt;the whole run spanning 3.89 seconds&lt;/strong&gt; so that link variation could not fall differently on the two sizes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;size&lt;/th&gt;
&lt;th&gt;duration&lt;/th&gt;
&lt;th&gt;throughput&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;8 MB&lt;/td&gt;
&lt;td&gt;0.418 s&lt;/td&gt;
&lt;td&gt;153.3 Mbps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;32 MB&lt;/td&gt;
&lt;td&gt;0.722 s&lt;/td&gt;
&lt;td&gt;354.6 Mbps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;8 MB&lt;/td&gt;
&lt;td&gt;0.438 s&lt;/td&gt;
&lt;td&gt;146.1 Mbps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;32 MB&lt;/td&gt;
&lt;td&gt;0.701 s&lt;/td&gt;
&lt;td&gt;365.1 Mbps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;8 MB&lt;/td&gt;
&lt;td&gt;0.362 s&lt;/td&gt;
&lt;td&gt;176.9 Mbps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;32 MB&lt;/td&gt;
&lt;td&gt;0.699 s&lt;/td&gt;
&lt;td&gt;366.3 Mbps&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;8 MB mean &lt;strong&gt;158.75&lt;/strong&gt; Mbps, 32 MB mean &lt;strong&gt;361.96&lt;/strong&gt; Mbps, a ratio of &lt;strong&gt;2.28x&lt;/strong&gt;. Every 32 MB run beat the 8 MB run beside it, and the two sets do not overlap at all — the fastest 8 MB run, 176.9, is below the slowest 32 MB run, 354.6.&lt;/p&gt;

&lt;p&gt;The mechanism is still open. A fixed-cost fit to those durations gives an asymptote of 637 Mbps and a startup cost of &lt;strong&gt;0.305 s&lt;/strong&gt;, which is about &lt;strong&gt;10 round trips&lt;/strong&gt; at this link's 29.6 ms, where slow-start to the ~503 KB bandwidth-delay product should need roughly 6. Something beyond textbook slow-start is in there, and six transfers cannot say what.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Same failure mode as the cable, one layer up.&lt;/strong&gt; A measurement instrument was producing a number that described the instrument rather than the link, and the only way out was interleaving the two conditions inside a window too short for anything else to change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cheat Sheet
&lt;/h2&gt;

&lt;p&gt;Both of these are in the repository and run directly, no arguments needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bin/tether-bus-check      &lt;span class="c"&gt;# everything below, plus a verdict. No cellular data&lt;/span&gt;
bin/tether-report         &lt;span class="c"&gt;# the full diagnostic rubric. No cellular data&lt;/span&gt;
bin/tether-report &lt;span class="nt"&gt;--measure&lt;/span&gt;   &lt;span class="c"&gt;# the standard pass. ~56 MB of metered data&lt;/span&gt;
bin/tether-interleave     &lt;span class="c"&gt;# A/B two transfer sizes against a moving link&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;tether-bus-check&lt;/code&gt; reads both sides of the negotiation and says which participant cast the low vote. &lt;code&gt;tether-interleave&lt;/code&gt; is the design from the section above — alternating sizes back to back so link drift falls on both equally — and it prints the cost before it spends it.&lt;/p&gt;

&lt;p&gt;The individual readings, if you would rather run them by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. which interface is the tether, and what driver&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; /sys/class/net/&lt;span class="k"&gt;*&lt;/span&gt;/device/driver&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;ifn&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;basename&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;dirname&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;dirname&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="si"&gt;)))&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;basename&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;readlink&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="si"&gt;))&lt;/span&gt; &lt;span class="k"&gt;in
    &lt;/span&gt;cdc_ncm|rndis_host|ipheth&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ifn&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;basename&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;readlink&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="si"&gt;))&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;;&lt;/span&gt; &lt;span class="k"&gt;esac&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;

&lt;span class="c"&gt;# 2. is it holding the default route (or are you about to measure Wi-Fi)&lt;/span&gt;
ip route show default

&lt;span class="c"&gt;# 3. THE READING THAT MATTERS - 480 / 5000 / 10000&lt;/span&gt;
&lt;span class="nv"&gt;dev&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;basename&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;readlink&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /sys/class/net/&amp;lt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/device/..&lt;span class="si"&gt;))&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/bus/usb/devices/&lt;span class="nv"&gt;$dev&lt;/span&gt;/speed

&lt;span class="c"&gt;# 4. could the device do better? if no SuperSpeed capability, stop here&lt;/span&gt;
lsusb &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &amp;lt;vid&amp;gt;:&amp;lt;pid&amp;gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A3&lt;/span&gt; SuperSpeed

&lt;span class="c"&gt;# 5. every external port, including the ones /sys/class/typec cannot see&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;p &lt;span class="k"&gt;in&lt;/span&gt; /sys/bus/usb/devices/usb&lt;span class="k"&gt;*&lt;/span&gt;/&lt;span class="k"&gt;*&lt;/span&gt;/usb&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="nt"&gt;-port&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="nv"&gt;$p&lt;/span&gt;/connect_type 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; hotplug &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;continue
  &lt;/span&gt;&lt;span class="nv"&gt;peer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"-"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="s2"&gt;/peer"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;peer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;basename&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;readlink&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="s2"&gt;/peer"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;basename&lt;/span&gt; &lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; peer=&lt;/span&gt;&lt;span class="nv"&gt;$peer&lt;/span&gt;&lt;span class="s2"&gt; location=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="nv"&gt;$p&lt;/span&gt;/location&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt;

&lt;span class="c"&gt;# 6. errors and drops - non-zero points at cable or power, not config&lt;/span&gt;
ip &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nb"&gt;link &lt;/span&gt;show &amp;lt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Steps 1 through 6 cost no cellular data. Only run a transfer once they have told you what you are looking at.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The goal of this article was to find out why a phone advertising 10 Gb/s would not negotiate above USB 2.0 speed on any cable or port, and what fixing it was worth. The key to the solution was treating the link rate as a three-way negotiation and reading both sides of it — what the link agreed on against what the device offered — then reading the port peer map rather than the Type-C connector list, so that a receptacle could be held constant while only the cable changed. The measured results were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A $15 cable took the bus from 480 to 5000 Mbps&lt;/strong&gt; on two handsets, on the same
physical receptacle, confirming that four earlier unmarked cables voted 480.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Pixel 9a's negotiated link went 425 to 3750 Mbps&lt;/strong&gt;, 8.8x, with the
receptacle and the DHCP lease held constant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Headroom went from 91% consumed to about 7% consumed.&lt;/strong&gt; The best USB 2.0 pass
in 45 records used 91% of what that bus can usably carry; the best SuperSpeed
passes use 7.3% and 7.4% of theirs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Four aggregates cleared 300 Mbps&lt;/strong&gt; — 370.695, 365.185 and 361.330, plus a
351.1 Mbps single 32 MB transfer — none of them reachable on a USB 2.0 bus.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;5000 and 10000 Mbps of bus measured the same&lt;/strong&gt; — 370.695 against 361.330 — so
once the ceiling clears the traffic, more ceiling buys nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Four cables and three ports produced an apparent 73 → 130 Mbps improvement that
was entirely the carrier recovering&lt;/strong&gt;, and zero USB errors throughout said so.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scope: one host, two handsets on AT&amp;amp;T, one physical location, measured 2026-09-05 and 2026-09-06, against a fixed endpoint and a fixed 8 MB transfer size across 45 records. The first SuperSpeed passes moved congestion control alongside the bus, running &lt;code&gt;cubic&lt;/code&gt; where the 2026-09-05 records ran &lt;code&gt;bbr&lt;/code&gt;; that confound has since been closed by matched BBR passes on 2026-09-08 — 365.185 Mbps aggregate against the CUBIC pass's 370.695 on the same port and bus — so the bus, not the congestion control, is what the SuperSpeed figures rest on. The 300 Mbps USB 2.0 bulk ceiling is the repository's own diagnostic constant rather than a measurement on this host, so the claim that the fast figures were unreachable on the old cable is a bound argument; no control run has yet put the fast link back onto a USB 2.0 cable, and that re-test is queued. &lt;code&gt;carrier.network&lt;/code&gt; is unobtainable on iOS, so carrier conditions across passes cannot be read directly and are inferred from RTT.&lt;/p&gt;

&lt;p&gt;The strategy for using a fixed rubric for USB tethering diagnosis was validated with an incremental step by step approach.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>networking</category>
      <category>usb</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Generosity Is a Default Setting</title>
      <dc:creator>xbill</dc:creator>
      <pubDate>Sat, 05 Sep 2026 22:04:33 +0000</pubDate>
      <link>https://dev.to/xbill/generosity-is-a-default-setting-2ig7</link>
      <guid>https://dev.to/xbill/generosity-is-a-default-setting-2ig7</guid>
      <description>&lt;p&gt;International Day of Charity falls on September 5, and the most generous thing in most software is not a donation button. It is a default.&lt;/p&gt;

&lt;p&gt;Defaults decide what happens to everyone who never opens the settings, which is almost everyone. A default that quietly spends a resource its user is short of is a small unkindness repeated at scale. A default that does not is a gift nobody has to ask for.&lt;/p&gt;

&lt;p&gt;Here is one worth changing. For a lot of people the phone is not a second connection — it is the connection, and tethering is how the laptop gets online without a second line, a second bill, or a router that has to be bought before anything works. That tether is often far slower than the radio it sits on, the reason is usually on the laptop rather than the carrier, and &lt;strong&gt;the fix is a default setting that costs nothing to change.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is a tool that finds out which problem you have without spending your data to do it, and the survey that taught it what to look for.&lt;/p&gt;

&lt;p&gt;Repository: &lt;a href="https://github.com/xbill9/tether" rel="noopener noreferrer"&gt;https://github.com/xbill9/tether&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;tether-report&lt;/code&gt; reads every host-observable fact about an attached USB tether, applies a diagnostic rubric, and prints a markdown report telling you what is wrong and what to change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;By default it measures nothing and costs no cellular data.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That default is the whole point. The standard measurement pass in this repo is three single-stream transfers, a four-stream parallel test and a ping — roughly 56 MB of metered data. On an unlimited home plan that is nothing. On a prepaid plan bought by the gigabyte, spending 56 MB to discover that your problem is a one-line setting is a bad trade, and it is exactly the trade most speed-test tools make you take before they tell you anything.&lt;/p&gt;

&lt;p&gt;So the tool splits the two. Everything the kernel already knows — the driver, the bus speed, the negotiated link rate, the MTU ceiling, the NCM aggregation buffers, the congestion control algorithm, the interface error counters — is free to read, and most real problems are visible in it. &lt;code&gt;--measure&lt;/code&gt; is opt-in, and it prints what it will cost before it spends it.&lt;/p&gt;

&lt;p&gt;The rubric it applies is the same one the survey was written against:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Shape&lt;/th&gt;
&lt;th&gt;Diagnosis&lt;/th&gt;
&lt;th&gt;Where the fix is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;🥇 Single-stream spread wide, RTT flat&lt;/td&gt;
&lt;td&gt;Congestion control collapsing on radio loss&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Your laptop.&lt;/strong&gt; Free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Single ≈ 4-stream aggregate&lt;/td&gt;
&lt;td&gt;Genuinely WAN-limited&lt;/td&gt;
&lt;td&gt;Nowhere. Nothing to tune&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Aggregate near 300 Mbps on a 480 Mbps bus&lt;/td&gt;
&lt;td&gt;USB 2.0 ceiling&lt;/td&gt;
&lt;td&gt;Cable or port&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RTT &lt;code&gt;mdev&lt;/code&gt; high, average unchanged&lt;/td&gt;
&lt;td&gt;Bufferbloat&lt;/td&gt;
&lt;td&gt;Depends what you run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spread flat, aggregate far above single&lt;/td&gt;
&lt;td&gt;Per-flow limit or shaping&lt;/td&gt;
&lt;td&gt;Carrier, probably&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first row is the one worth having, because it is the only one where the answer is both free and on your side of the link.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;A Pixel 9a on Google Fi, attached over USB-C. This is a real run, captured while writing this article, and it spent &lt;strong&gt;no cellular data at all&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;python3 bin/tether-report
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It opens with what the link actually is:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Interface&lt;/td&gt;
&lt;td&gt;&lt;code&gt;enxce1d58e89c0f&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Driver&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cdc_ncm&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IPv4&lt;/td&gt;
&lt;td&gt;&lt;code&gt;10.244.144.215/24&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MTU / max&lt;/td&gt;
&lt;td&gt;1500 / 1500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Negotiated link&lt;/td&gt;
&lt;td&gt;425 Mbps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Holds default route&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Then the bus, which is where the ceiling usually is:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Device&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;3-2&lt;/code&gt; 18d1:4eec - Pixel 9a&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bus speed&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;480 Mbps&lt;/strong&gt; (USB 2.0)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BOS capability&lt;/td&gt;
&lt;td&gt;SuperSpeed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Type-C connector&lt;/td&gt;
&lt;td&gt;&lt;code&gt;port1&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And then the part that matters — the rubric's verdict on all of it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;h3&gt;
  
  
  BLOCKER: Another route-capable link is up
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;wlo1&lt;/code&gt; is up alongside the tether. If the tether drops mid-run, curl still succeeds and the result looks like a tethering measurement. Take these down first.&lt;/p&gt;
&lt;h3&gt;
  
  
  Problem: Device can do better than 480 Mbps
&lt;/h3&gt;

&lt;p&gt;The device advertises SuperSpeed in its BOS descriptor but enumerated at 480 Mbps. Connector &lt;code&gt;port1&lt;/code&gt; has an enumerated SuperSpeed half, so the host is declared capable and &lt;strong&gt;the cable is the leading suspect&lt;/strong&gt; — an unmarked C-to-C cable is very often USB 2.0.&lt;/p&gt;
&lt;h3&gt;
  
  
  Note: NCM aggregation buffers
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;rx_max=16384&lt;/code&gt;, &lt;code&gt;tx_max=16384&lt;/code&gt;. Compare these against the device-advertised &lt;code&gt;dwNtbInMaxSize&lt;/code&gt;/&lt;code&gt;dwNtbOutMaxSize&lt;/code&gt;: &lt;strong&gt;if they already match, the common "raise these to 32768" advice is a no-op&lt;/strong&gt; and changing them will do nothing.&lt;/p&gt;
&lt;h3&gt;
  
  
  Note: No measurements taken
&lt;/h3&gt;

&lt;p&gt;Run again with &lt;code&gt;--measure&lt;/code&gt; for the throughput and latency half of the rubric. That costs roughly 56 MB of metered cellular data.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Four useful things about this link, and the price was zero.&lt;/p&gt;

&lt;p&gt;The blocker is the one I want to point at. Wi-Fi was still up, so any transfer run at that moment could have gone out over Wi-Fi and come back looking like a tethering result. That is not a hypothetical — it is the most common way a tethering measurement quietly becomes a measurement of something else, and a tool that charged you 56 MB before mentioning it would have charged you for a number that meant nothing.&lt;/p&gt;

&lt;p&gt;The last note is the design in one line. It did not measure. It told me what measuring would cost and let me decide.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;One difference from the records in this repo: the tether was enabled here with &lt;code&gt;adb shell svc usb setFunctions ncm&lt;/code&gt; rather than the phone's own tethering toggle, and it enumerated as &lt;code&gt;18d1:4eec&lt;/code&gt; where the recorded passes show &lt;code&gt;18d1:4eeb&lt;/code&gt;. Same driver and same interface, slightly different USB function composition.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;Everything is in one public repository: &lt;a href="https://github.com/xbill9/tether" rel="noopener noreferrer"&gt;https://github.com/xbill9/tether&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;bin/tether-report&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The diagnostic. One file, standard library only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;README.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The format spec, field reference and the diagnostic rubric&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tests/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;40 measurement records, one markdown file each&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;INDEX.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One row per test&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TEMPLATE.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The skeleton a new record is copied from&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;tether-report&lt;/code&gt; has no dependencies beyond the Python standard library and the &lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;ping&lt;/code&gt; and &lt;code&gt;ip&lt;/code&gt; binaries. That is deliberate: a tool for people on expensive connections should not begin by downloading a dependency tree.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/xbill9/tether
&lt;span class="nb"&gt;cd &lt;/span&gt;tether
python3 bin/tether-report
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No install step, no virtualenv, no package manager. If it runs, it runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The measurements came first, the tool second
&lt;/h3&gt;

&lt;p&gt;The tool is a rubric with a reader attached, and the rubric came out of taking the same measurement forty times and noticing which distinctions actually separated one failure from another.&lt;/p&gt;

&lt;p&gt;Three rules turned out to matter, and all three exist because ignoring them produced a wrong answer first:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Record three runs, never one.&lt;/strong&gt; The Pixel 9a produced 15, 44 and 116 Mbps on three consecutive identical transfers. Any one of those numbers, reported alone, is a lie about the link. The spread is the finding.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Always run the parallel test too.&lt;/strong&gt; Single-stream against four-stream aggregate is the one comparison that separates "the carrier is slow" from "congestion control is collapsing on this machine". If four streams together go much faster than one, the WAN has headroom and the problem is local. That distinction drove every fix worth making.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Record the congestion control algorithm.&lt;/strong&gt; It is the largest single lever available, and a record without it cannot be interpreted.&lt;/p&gt;

&lt;h3&gt;
  
  
  The finding
&lt;/h3&gt;

&lt;p&gt;Same phone, same cable, same session. Only the host TCP settings changed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;net&lt;/span&gt;.&lt;span class="n"&gt;ipv4&lt;/span&gt;.&lt;span class="n"&gt;tcp_congestion_control&lt;/span&gt; = &lt;span class="n"&gt;bbr&lt;/span&gt;
&lt;span class="n"&gt;net&lt;/span&gt;.&lt;span class="n"&gt;ipv4&lt;/span&gt;.&lt;span class="n"&gt;tcp_slow_start_after_idle&lt;/span&gt; = &lt;span class="m"&gt;0&lt;/span&gt;
&lt;span class="n"&gt;net&lt;/span&gt;.&lt;span class="n"&gt;ipv4&lt;/span&gt;.&lt;span class="n"&gt;tcp_mtu_probing&lt;/span&gt; = &lt;span class="m"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Single-stream runs (Mbps)&lt;/th&gt;
&lt;th&gt;Spread&lt;/th&gt;
&lt;th&gt;RTT avg&lt;/th&gt;
&lt;th&gt;RTT mdev&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CUBIC, as shipped&lt;/td&gt;
&lt;td&gt;15 / 44 / 116&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7.73x&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;29.6 ms&lt;/td&gt;
&lt;td&gt;4.1 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BBR&lt;/td&gt;
&lt;td&gt;125 / 153 / 106&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.44x&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;29.7 ms&lt;/td&gt;
&lt;td&gt;5.8 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The worst run went from &lt;strong&gt;15 Mbps to 106 Mbps&lt;/strong&gt;. Average round-trip time did not move — 29.6 to 29.7 ms — which is what rules out the boring explanation that BBR simply bought throughput by filling a queue.&lt;/p&gt;

&lt;p&gt;The mechanism is that CUBIC treats packet loss as a congestion signal. A radio drops packets for reasons that have nothing to do with congestion, CUBIC halves its window anyway, and a single flow spends its life recovering. BBR models the path instead of reacting to loss, so random drops stop being interpreted as a full pipe.&lt;/p&gt;

&lt;h3&gt;
  
  
  The part that generalises, and the part that does not
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;This is a mechanism argument, not a second measurement, and it should be read as one.&lt;/strong&gt; CUBIC's failure is triggered by random loss. Weak signal produces more random loss. So the worse the radio, the more this setting should be worth — and the people on the worst radios are the ones for whom a free fix matters most.&lt;/p&gt;

&lt;p&gt;I have not measured that. Every record in this survey was taken on Google Fi or AT&amp;amp;T, in one location, on networks whose 120 individual transfers ran from 5.3 to 163.0 Mbps with a median of 69.8. Nothing here was collected anywhere under-served, and I am not going to claim otherwise. What I can say is that the mechanism does not depend on geography, that the fix costs nothing to try, and that &lt;code&gt;tether-report&lt;/code&gt; will tell you for free whether your link has the shape the fix addresses.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the survey actually showed
&lt;/h3&gt;

&lt;p&gt;Across 40 records and 14 handsets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every single record enumerated at 480 Mbps.&lt;/strong&gt; Fourteen handsets, three
drivers, and not one USB 3.0 negotiation anywhere in 40 records. The bus is a
more consistent ceiling than the radio. On the one phone chased hardest — an
iPhone 17 Pro that advertises SuperSpeedPlus — 480 survived four cables and
three physical ports, and that hunt is still open in the repo. No cable tried
was verifiably wired for SuperSpeed, so those four results may be closer to one
result repeated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The driver is not the story.&lt;/strong&gt; &lt;code&gt;cdc_ncm&lt;/code&gt;, &lt;code&gt;rndis_host&lt;/code&gt; and &lt;code&gt;ipheth&lt;/code&gt; all
appear at both ends of the results.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The radio label predicts nothing.&lt;/strong&gt; Two Galaxy S24 units differing only in
radio — 5G against LTE — and the LTE unit won on every measure, including
14.4 ms of round-trip time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-running the same phone moves the answer more than changing phones does.&lt;/strong&gt;
Which is the finding that makes a single-run speed test close to worthless.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Scope and limits
&lt;/h3&gt;

&lt;p&gt;Forty records, fourteen handsets, two carriers (Google Fi and AT&amp;amp;T), one host, one physical location, all on 2026-09-05. The endpoint and transfer size are fixed by the README so records stay comparable. The CUBIC-against-BBR comparison rests on &lt;strong&gt;two&lt;/strong&gt; CUBIC records against 38 BBR ones, so it should be read as a strong result on one phone plus a consistent second case, not as a survey-wide average. Radio type is unrecorded on several records because a reading taken after a pass does not establish what was in use during it, and those fields are left blank rather than guessed. No measurement in this repository has ever been edited; a re-test after a configuration change is always a new file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three defaults, and who pays for them
&lt;/h2&gt;

&lt;p&gt;The theme this weekend is generosity, and the argument of this piece is that defaults are where most of it either happens or does not. Three of them here:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The diagnostic's default spends nothing.&lt;/strong&gt; Reading the kernel is free, so
the free half does the diagnosing and &lt;code&gt;--measure&lt;/code&gt; is opt-in and prices itself
before it runs. The alternative default — measure first, explain later — bills
the user in the exact currency they came in short of.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The fix is a default someone else already chose for you.&lt;/strong&gt; CUBIC ships as
the kernel's congestion control, it is the wrong choice on a lossy radio, and
changing it costs no hardware, no plan change and no subscription. Every phone
in this survey was carrying that cost silently until it was measured.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The method is published, not just the conclusion.&lt;/strong&gt; The rubric, the record
format, all 40 records and the reasoning behind them are in the repository,
against a fixed endpoint and transfer size so a stranger's numbers compare to
these. A conclusion you cannot check is a thing you have to take on trust; a
method is a thing you can own.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The most useful thing I can give someone whose connection is their phone is not my numbers. It is a way to get their own, at no cost, and a fix that was already paid for before they arrived.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;None. This entry does not use Snowflake, Solana, ElevenLabs or Google AI, so it is submitted to the overall category only.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The goal of this article was to make a slow USB tether diagnosable without spending metered data to diagnose it. The key to the solution was separating what the kernel already knows from what has to be paid for in bytes, and putting the entire rubric on the free side. The measured results were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Worst single-stream run moved from &lt;strong&gt;15 Mbps to 106 Mbps&lt;/strong&gt; on one free setting&lt;/li&gt;
&lt;li&gt;Single-stream spread collapsed from &lt;strong&gt;7.73x to 1.44x&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Round-trip time was unchanged at &lt;strong&gt;29.6 against 29.7 ms&lt;/strong&gt;, ruling out queue depth&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;40 records, 14 handsets, 0 that negotiated USB 3.0&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Measured on one host across two US carriers in a single location on 2026-09-05, three single-stream runs plus a four-stream parallel pass per record, against a fixed endpoint and transfer size; the congestion-control comparison rests on two CUBIC records against 38 BBR ones.&lt;/p&gt;

&lt;p&gt;The strategy for using a fixed rubric for tethering diagnosis was validated with an incremental step by step approach.&lt;/p&gt;

</description>
      <category>weekendchallenge</category>
      <category>linux</category>
      <category>networking</category>
      <category>python</category>
    </item>
    <item>
      <title>AWS Has Two Iceberg REST Catalogs: What Each One Actually Serves</title>
      <dc:creator>xbill</dc:creator>
      <pubDate>Fri, 04 Sep 2026 18:29:18 +0000</pubDate>
      <link>https://dev.to/aws-builders/aws-has-two-iceberg-rest-catalogs-what-each-one-actually-serves-2bob</link>
      <guid>https://dev.to/aws-builders/aws-has-two-iceberg-rest-catalogs-what-each-one-actually-serves-2bob</guid>
      <description>&lt;p&gt;This article provides a step by step comparison of the two Apache Iceberg REST catalog implementations AWS ships — AWS Glue and Amazon S3 Tables — measuring what each one actually serves. A Python probe harness issues one identical request suite to both and stores the raw response as evidence.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/xbill9/lakehouse-iceberg-2026" rel="noopener noreferrer"&gt;https://github.com/xbill9/lakehouse-iceberg-2026&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;AWS ships two Iceberg REST catalogs. Both are managed, both are SigV4-signed, and both implement the same published specification. If you are choosing between them, or writing a client that has to work against either, the interesting question is where they diverge.&lt;/p&gt;

&lt;p&gt;They score identically and behave differently in thirteen places.&lt;/p&gt;

&lt;p&gt;All results below were measured on 2026-09-03.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is the Iceberg REST Catalog?
&lt;/h2&gt;

&lt;p&gt;An Iceberg table is a directory of Parquet files plus a chain of JSON metadata files recording which files belong to the table right now. Something has to hold the pointer to the current metadata file, and make commits atomic by swapping it. That is the catalog.&lt;/p&gt;

&lt;p&gt;The REST catalog is one HTTP API for that job, so an engine needs one driver rather than one per catalog. The specification lives in the Iceberg repository as &lt;code&gt;open-api/rest-catalog-open-api.yaml&lt;/code&gt; and defines 35 operations.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-sL&lt;/span&gt; https://raw.githubusercontent.com/apache/iceberg/main/open-api/rest-catalog-open-api.yaml &lt;span class="nt"&gt;-o&lt;/span&gt; irc.yaml
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-cE&lt;/span&gt; &lt;span class="s1"&gt;'^    (get|post|delete|head|put):'&lt;/span&gt; irc.yaml
&lt;span class="go"&gt;35
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This harness probes 25 of those 35, or 71%.&lt;/p&gt;

&lt;h2&gt;
  
  
  At This Point You Should Have
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An AWS account, and credentials with enough privilege to create a Glue database,
an S3 bucket and an S3 Tables table bucket&lt;/li&gt;
&lt;li&gt;Python 3.13 with &lt;code&gt;requests&lt;/code&gt;, &lt;code&gt;pyiceberg&lt;/code&gt; and &lt;code&gt;botocore&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;botocore&lt;/code&gt; available for SigV4 signing
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;aws sts get-caller-identity &lt;span class="nt"&gt;--query&lt;/span&gt; Arn &lt;span class="nt"&gt;--output&lt;/span&gt; text
&lt;span class="go"&gt;arn:aws:iam::AWS_ACCOUNT_ID:root
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
&lt;/span&gt;&lt;span class="go"&gt;import sys, pyiceberg, requests, botocore
print('python    ', sys.version.split()[0])
print('pyiceberg ', pyiceberg.__version__)
print('requests  ', requests.__version__)
print('botocore  ', botocore.__version__)"
python     3.13.13
pyiceberg  0.12.0
requests   2.34.2
botocore   1.43.34
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything below was measured with account root, so no result here is a permissions artefact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Are the Two Endpoints?
&lt;/h2&gt;

&lt;p&gt;They are different services with different signing names.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;AWS Glue&lt;/th&gt;
&lt;th&gt;Amazon S3 Tables&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Endpoint&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://glue.us-east-1.amazonaws.com/iceberg&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://s3tables.us-east-1.amazonaws.com/iceberg&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SigV4 signing name&lt;/td&gt;
&lt;td&gt;&lt;code&gt;glue&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;s3tables&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Warehouse&lt;/td&gt;
&lt;td&gt;the account id&lt;/td&gt;
&lt;td&gt;the table bucket ARN&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# GET /v1/config against Glue&lt;/span&gt;
&lt;span class="go"&gt;{"defaults":{"header.Content-Type":"application/x-amz-json-1.1","rest.sigv4-enabled":"true",
 "rest-table-scan-enabled":"true","prefix":"AWS_ACCOUNT_ID","rest.signing-region":"us-east-1",
 "rest.signing-name":"glue", ...}}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Signing Is Where the First Hour Goes
&lt;/h2&gt;

&lt;p&gt;SigV4 signs the exact query string, so the URL you sign must be the URL you send. Building the URL and then letting an HTTP client re-encode the parameters separately produces a different canonical string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# signing one URL, sending another&lt;/span&gt;
&lt;span class="go"&gt;HTTP 403
{"message":"The request signature we calculated does not match the signature you
provided. Check your AWS Secret Access Key and signing method."}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Build it once and send it whole. &lt;code&gt;urlencode&lt;/code&gt;'s default &lt;code&gt;quote_plus&lt;/code&gt; is also wrong here, because SigV4 wants &lt;code&gt;%20&lt;/code&gt; rather than &lt;code&gt;+&lt;/code&gt;. With both fixed, the same request reaches the service and returns a real answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# same probe, correct canonical query string&lt;/span&gt;
&lt;span class="go"&gt;HTTP 404
{"error":{"code":404,"message":"The specified bucket does not exist.","type":"no_such_bucket"}}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 403 signature error and a 404 no-such-bucket look equally like failure in a log. Only one of them is about the catalog.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bringing Up AWS Glue
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;aws s3api create-bucket &lt;span class="nt"&gt;--bucket&lt;/span&gt; &lt;span class="nv"&gt;$BUCKET&lt;/span&gt; &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;aws glue create-database &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1 &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="gp"&gt;    --database-input "{\"Name\":\"probe_ns\",\"LocationUri\":\"s3://$&lt;/span&gt;BUCKET/probe_ns/&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;aws glue get-databases --region us-east-1 --query 'DatabaseList[].Name' --output text
&lt;/span&gt;&lt;span class="go"&gt;probe_ns
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Glue rejects &lt;code&gt;createTable&lt;/code&gt; without an explicit table location:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# createTable with no location field&lt;/span&gt;
&lt;span class="go"&gt;InvalidInputException: Location information cannot be null while creating an iceberg table
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every other catalog in this suite infers it from the warehouse. Glue does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bringing Up Amazon S3 Tables
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;ARN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws s3tables create-table-bucket &lt;span class="nt"&gt;--name&lt;/span&gt; iceberg-probe &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="go"&gt;    --region us-east-1 --query arn --output text)
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$ARN&lt;/span&gt;
&lt;span class="go"&gt;arn:aws:s3tables:us-east-1:AWS_ACCOUNT_ID:bucket/iceberg-probe
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;aws s3tables create-namespace &lt;span class="nt"&gt;--table-bucket-arn&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ARN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="go"&gt;    --namespace probe_ns --region us-east-1
{"tableBucketARN": "arn:aws:s3tables:us-east-1:AWS_ACCOUNT_ID:bucket/iceberg-probe",
 "namespace": ["probe_ns"]}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two constraints surface only when you hit them. Namespace names reject uppercase, which a timestamped scratch namespace will contain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# createNamespace named irc_probe_20260903T1626&lt;/span&gt;
&lt;span class="go"&gt;HTTP 400
The specified namespace name isn't valid. Specify a different namespace name, and
then try again.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;createTable&lt;/code&gt; requires &lt;code&gt;stage-create&lt;/code&gt; in the body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# createTable without it&lt;/span&gt;
&lt;span class="go"&gt;HTTP 400  stage-create is a required field and cannot be null
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seeding a table also failed against the managed bucket with &lt;code&gt;pyiceberg&lt;/code&gt;'s default writer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# appending with the default PyArrow FileIO&lt;/span&gt;
&lt;span class="go"&gt;AWS Error [code 134] during CreateMultipartUpload operation: The authorization
mechanism you have provided is not supported. Please use Signature Version 4.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Switching to &lt;code&gt;pyiceberg.io.fsspec.FsspecFileIO&lt;/code&gt; worked. Note the bucket name in that error is not the one you created — S3 Tables stores data in a managed bucket of its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Prefixes Do Not Look Alike
&lt;/h2&gt;

&lt;p&gt;Every client reads the routing prefix from &lt;code&gt;/v1/config&lt;/code&gt; and puts it in every later URL. The two services return very different shapes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Catalog&lt;/th&gt;
&lt;th&gt;Prefix as returned&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Glue&lt;/td&gt;
&lt;td&gt;&lt;code&gt;catalogs/AWS_ACCOUNT_ID&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3 Tables&lt;/td&gt;
&lt;td&gt;&lt;code&gt;arn%3Aaws%3As3tables%3Aus-east-1%3A...%3Abucket%2Ficeberg-probe&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Glue returns two path segments. S3 Tables returns a percent-encoded ARN. A client that assumes one segment, or that re-encodes what it was handed, produces URLs neither service routes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Scores Are Identical
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;🥈 AWS Glue&lt;/th&gt;
&lt;th&gt;🥈 Amazon S3 Tables&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Read probes served&lt;/td&gt;
&lt;td&gt;9/15&lt;/td&gt;
&lt;td&gt;9/15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Write probes served&lt;/td&gt;
&lt;td&gt;10/17&lt;/td&gt;
&lt;td&gt;10/17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Not tested&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;loadTable&lt;/code&gt; fields present&lt;/td&gt;
&lt;td&gt;26/30&lt;/td&gt;
&lt;td&gt;27/30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Endpoints declared in &lt;code&gt;/v1/config&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read and write surfaces are scored separately rather than summed, and probes whose prerequisite failed are excluded rather than counted as failures.&lt;/p&gt;

&lt;p&gt;That table is the least interesting thing in this article. The two implementations arrive at the same totals by different routes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Neither One Tells You What It Supports
&lt;/h2&gt;

&lt;p&gt;The specification lets a server advertise its own surface:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;endpoints&lt;/strong&gt;: A list of endpoints that the server supports.&lt;/p&gt;

&lt;p&gt;— &lt;code&gt;rest-catalog-open-api.yaml&lt;/code&gt;, &lt;code&gt;CatalogConfig&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Five of the seven catalogs in the wider comparison publish that array. Neither AWS catalog does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
&lt;/span&gt;&lt;span class="go"&gt;import json
for c in ('aws-glue','aws-s3tables'):
    d = json.load(open('evidence/%s.json' % c))
    print('%-14s declares %s endpoints' % (c, len(d.get('declared_endpoints') or []) or 'no'))
"
aws-glue       declares no endpoints
aws-s3tables   declares no endpoints
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The field is optional, so this is not a specification violation. It does mean capability discovery is unavailable on both, and a client has no way to learn what either serves short of probing it — which is what this harness does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Thirteen Probes Behave Differently
&lt;/h2&gt;

&lt;p&gt;Of 33 probes, 20 return the same verdict on both and 13 do not.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Probe&lt;/th&gt;
&lt;th&gt;AWS Glue&lt;/th&gt;
&lt;th&gt;Amazon S3 Tables&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;list_views&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;❌ 406 not supported&lt;/td&gt;
&lt;td&gt;❌ 404 unknown operation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;create_view&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;❌ 406 not supported&lt;/td&gt;
&lt;td&gt;❌ 404 unknown operation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;load_view&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;❌ 406 not supported&lt;/td&gt;
&lt;td&gt;❌ 404 unknown operation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;replace_view&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;❌ 406 not supported&lt;/td&gt;
&lt;td&gt;❌ 404 unknown operation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rename_view&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;❌ 406 not supported&lt;/td&gt;
&lt;td&gt;❌ 404 unknown operation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;drop_view&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;❌ 406 not supported&lt;/td&gt;
&lt;td&gt;❌ 404 unknown operation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rename_table&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;❌ 406 not supported&lt;/td&gt;
&lt;td&gt;✅ 204&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;update_namespace_props&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅ 200&lt;/td&gt;
&lt;td&gt;❌ 404 unknown operation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;plan_table_scan&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;⚠️ 200 with exception&lt;/td&gt;
&lt;td&gt;❌ 404 unknown operation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;report_metrics&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;⚠️ 200 with exception&lt;/td&gt;
&lt;td&gt;❌ 400 not supported&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;commit_transaction&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;⚠️ 200 with exception&lt;/td&gt;
&lt;td&gt;❌ 404 unknown operation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;drop_table_purge&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;❌ 400 purge forbidden&lt;/td&gt;
&lt;td&gt;✅ 204&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;drop_table&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅ 204&lt;/td&gt;
&lt;td&gt;❌ 400 purge required&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Neither implements views, but they refuse differently, and one of them renames tables while the other does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Glue Names the Operation It Is Refusing
&lt;/h2&gt;

&lt;p&gt;Glue's refusals are specific, and they name the endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# POST .../views against Glue&lt;/span&gt;
&lt;span class="go"&gt;HTTP 406  CreateView endpoint is not supported for Glue Catalog.
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# POST .../tables/rename against Glue&lt;/span&gt;
&lt;span class="go"&gt;HTTP 406  RenameTable endpoint is not supported for Glue Catalog.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;S3 Tables returns a bare XML document with no operation name and no JSON error body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# POST .../views against S3 Tables&lt;/span&gt;
&lt;span class="gp"&gt;HTTP 404  &amp;lt;UnknownOperationException/&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both mean the same thing to a user and not to a program. Glue's &lt;code&gt;406&lt;/code&gt; with a named operation is machine-readable enough to log usefully; the bare exception is not.&lt;/p&gt;

&lt;p&gt;The exception to S3 Tables' silence is &lt;code&gt;report_metrics&lt;/code&gt;, which is the one place it says what it means:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# POST .../tables/{table}/metrics against S3 Tables&lt;/span&gt;
&lt;span class="go"&gt;HTTP 400  ReportMetrics is currently not supported.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Glue Answers Three Unrouted Operations With HTTP 200
&lt;/h2&gt;

&lt;p&gt;This is the finding a client author should care about most.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# POST .../tables/{table}/plan against Glue&lt;/span&gt;
&lt;span class="go"&gt;HTTP 200
&lt;/span&gt;&lt;span class="gp"&gt;{"Output": {"__type": "com.amazon.coral.service#&lt;/span&gt;UnknownOperationException&lt;span class="s2"&gt;"}, "&lt;/span&gt;Version&lt;span class="s2"&gt;": "&lt;/span&gt;1.0&lt;span class="s2"&gt;"}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same body comes back from &lt;code&gt;report_metrics&lt;/code&gt; and &lt;code&gt;commit_transaction&lt;/code&gt;. The mechanism is the AWS protocol layer answering an operation its front door does not route, rather than Glue returning a broken success for an implemented endpoint.&lt;/p&gt;

&lt;p&gt;The consequence does not depend on the mechanism. Code that branches on the status code sees three endpoints that work, and only code that parses the body finds out otherwise. The harness gives these their own verdict rather than scoring them &lt;code&gt;OK&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Glue's &lt;code&gt;/v1/config&lt;/code&gt; also advertises &lt;code&gt;rest-table-scan-enabled: true&lt;/code&gt;, and scan planning is one of the three.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Drop Requirements Are Opposite
&lt;/h2&gt;

&lt;p&gt;Glue refuses a purge drop. S3 Tables refuses a plain one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# DELETE .../tables/{table}?purgeRequested=true against Glue&lt;/span&gt;
&lt;span class="go"&gt;HTTP 400  PurgeRequested cannot be true for Glue iceberg tables.

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# DELETE .../tables/{table} against S3 Tables&lt;/span&gt;
&lt;span class="go"&gt;HTTP 400  DropTable operation failed. S3 Tables only supports dropping tables with
purge enabled.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two products have different storage models, so this is defensible rather than a defect — Glue points at a bucket you own, and S3 Tables owns the storage it drops. It is still the sharpest example of why "AWS supports the Iceberg REST catalog" is not a sentence a client can act on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Neither Supports Multi-Level Namespaces
&lt;/h2&gt;

&lt;p&gt;Both reject the &lt;code&gt;parent&lt;/code&gt; query parameter on &lt;code&gt;listNamespaces&lt;/code&gt;, and both say so plainly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# GET .../namespaces?parent=probe_ns against Glue&lt;/span&gt;
&lt;span class="go"&gt;HTTP 400  Glue dataCatalog does not support multipart namespace.
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# the same against S3 Tables&lt;/span&gt;
&lt;span class="go"&gt;HTTP 400  Multipart namespaces are not supported.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Where They Agree
&lt;/h2&gt;

&lt;p&gt;Twenty of the 33 probes return the same verdict, and the agreement is the core of the specification. Both serve config, namespace listing and loading, table listing and loading, &lt;code&gt;loadTable&lt;/code&gt; with full snapshot history, and both accept all five &lt;code&gt;updateTable&lt;/code&gt; actions probed — &lt;code&gt;set-properties&lt;/code&gt;, &lt;code&gt;remove-properties&lt;/code&gt;, &lt;code&gt;add-schema&lt;/code&gt;, &lt;code&gt;set-current-schema&lt;/code&gt; and &lt;code&gt;upgrade-format-version&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Both also return nearly identical &lt;code&gt;loadTable&lt;/code&gt; documents: 26 of 30 checked specification field paths on Glue, 27 on S3 Tables. The single extra on S3 Tables is a &lt;code&gt;config&lt;/code&gt; block. Whatever separates these two, it is not the fidelity of the metadata they return.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The goal of this article was to measure what AWS's two Iceberg REST catalog implementations actually serve, rather than what "supports the REST catalog" implies. The key to the solution was issuing one identical request suite to both and storing the raw response for every probe. The comparison results were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Identical totals, thirteen behavioural differences.&lt;/strong&gt; Both serve 9 of 15 read
probes and 10 of 17 write probes, and 20 of 33 probes agree.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Neither publishes an &lt;code&gt;endpoints&lt;/code&gt; declaration&lt;/strong&gt;, so capability discovery is
unavailable on both and a client must probe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Glue answers three unrouted operations with HTTP 200&lt;/strong&gt; carrying an
&lt;code&gt;UnknownOperationException&lt;/code&gt;, one of which is the scan planning its own config
advertises as enabled.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Neither implements views&lt;/strong&gt;, and they refuse differently — Glue with a &lt;code&gt;406&lt;/code&gt;
naming the operation, S3 Tables with a bare &lt;code&gt;&amp;lt;UnknownOperationException/&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The drop requirements are opposite.&lt;/strong&gt; Glue forbids &lt;code&gt;purgeRequested=true&lt;/code&gt;, S3
Tables requires it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;rename_table&lt;/code&gt; works on S3 Tables and not on Glue.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scope: both catalogs probed once in us-east-1 on 2026-09-03 with account root, against one table shape seeded through pyiceberg 0.12.0, covering 25 of the specification's 35 operations, 5 of its 25 update actions and 1 of its 8 table requirements; neither service exposes a version, so no result here can be tied to a release; and the field tier records that a value is present, never that it is correct.&lt;/p&gt;

&lt;p&gt;The strategy for comparing two managed catalogs against one specification was validated with an incremental step by step approach.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>iceberg</category>
      <category>lakehouse</category>
      <category>dataengineering</category>
    </item>
    <item>
      <title>Seven Iceberg REST Catalogs: What They Declare, and What They Serve</title>
      <dc:creator>xbill</dc:creator>
      <pubDate>Fri, 04 Sep 2026 18:29:02 +0000</pubDate>
      <link>https://dev.to/gde/seven-iceberg-rest-catalogs-what-they-declare-and-what-they-serve-40oj</link>
      <guid>https://dev.to/gde/seven-iceberg-rest-catalogs-what-they-declare-and-what-they-serve-40oj</guid>
      <description>&lt;p&gt;This article provides a step by step comparison of seven Apache Iceberg REST catalog implementations, measuring what each one actually serves against the published specification. A Python probe harness is built to issue one identical request suite to every catalog and store the raw response as evidence.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/xbill9/lakehouse-iceberg-2026" rel="noopener noreferrer"&gt;https://github.com/xbill9/lakehouse-iceberg-2026&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every vendor with a lakehouse now ships an Iceberg REST catalog, and every one of them says it implements the same specification. That claim is checkable. The specification also asks a server to publish a machine-readable list of the endpoints it supports, which makes a second claim checkable: does a catalog agree with itself?&lt;/p&gt;

&lt;p&gt;All results below were measured on 2026-09-03.&lt;/p&gt;

&lt;p&gt;Nothing here is about the REST catalog being broken. It works — every catalog served the core read path.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Do You Need to Reproduce This?
&lt;/h2&gt;

&lt;p&gt;Seven catalogs means seven accounts, and three of mine were trials that will have expired by the time most people read this. Read this section before investing an afternoon.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Catalog&lt;/th&gt;
&lt;th&gt;What you need&lt;/th&gt;
&lt;th&gt;What I used&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Apache Polaris&lt;/td&gt;
&lt;td&gt;Docker&lt;/td&gt;
&lt;td&gt;local container, free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google BigLake&lt;/td&gt;
&lt;td&gt;GCP project, Lakehouse API enabled&lt;/td&gt;
&lt;td&gt;owned project&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS Glue&lt;/td&gt;
&lt;td&gt;AWS account&lt;/td&gt;
&lt;td&gt;owned account&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS S3 Tables&lt;/td&gt;
&lt;td&gt;AWS account&lt;/td&gt;
&lt;td&gt;owned account&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Databricks Unity&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Premium&lt;/strong&gt; workspace&lt;/td&gt;
&lt;td&gt;14-day trial&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Snowflake Horizon&lt;/td&gt;
&lt;td&gt;any Snowflake account&lt;/td&gt;
&lt;td&gt;30-day Enterprise trial&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Microsoft OneLake&lt;/td&gt;
&lt;td&gt;Fabric licence on a &lt;strong&gt;work&lt;/strong&gt; account&lt;/td&gt;
&lt;td&gt;60-day Fabric trial capacity&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three of those need saying plainly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Databricks Free Edition will not work.&lt;/strong&gt; Enabling external data access is an account-level action, and Free Edition documents no access to the account console.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A personal Microsoft account cannot hold a Fabric licence.&lt;/strong&gt; The Fabric API returns &lt;code&gt;UserNotLicensed&lt;/code&gt; and no configuration changes it. A work or school account in an Entra tenant can, and the free 60-day trial capacity is enough — the paid F-SKU the Azure portal steers you toward is not needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Snowflake Open Catalog is closed to new signups.&lt;/strong&gt; Snowflake's documentation directs new customers to Horizon, which is what this measures.&lt;/p&gt;

&lt;p&gt;Being on a trial tier is also a genuine confound in the results, not only an inconvenience. The limitations section returns to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is Apache Iceberg?
&lt;/h2&gt;

&lt;p&gt;Iceberg is a table format. A table is a directory of Parquet files plus a chain of JSON metadata files recording which of those files belong to the table right now, what the schema is, how it is partitioned, and what it looked like at every previous commit. That history is why engines can time-travel and why two writers can commit without corrupting each other.&lt;/p&gt;

&lt;p&gt;Something has to hold the pointer to the current metadata file. That something is the catalog. It answers one question — for table &lt;code&gt;X&lt;/code&gt;, where is the metadata right now — and it makes commits atomic by swapping that pointer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is the REST Catalog?
&lt;/h2&gt;

&lt;p&gt;For years each engine brought its own catalog: Hive Metastore, a Glue client, a JDBC catalog, a filesystem convention. Every engine needed a driver for every catalog.&lt;/p&gt;

&lt;p&gt;The Iceberg REST catalog replaces that with one HTTP API. A client speaks HTTP to a URL and the vendor implements the endpoints behind it. The specification lives in the Iceberg repository as &lt;code&gt;open-api/rest-catalog-open-api.yaml&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-sL&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; irc.yaml https://raw.githubusercontent.com/apache/iceberg/main/open-api/rest-catalog-open-api.yaml
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;python3 - &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
&lt;/span&gt;&lt;span class="go"&gt;import re
lines = open('irc.yaml').read().split('\n')
&lt;/span&gt;&lt;span class="gp"&gt;inpaths = False;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;cur &lt;span class="o"&gt;=&lt;/span&gt; None&lt;span class="p"&gt;;&lt;/span&gt; ops &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;[]&lt;/span&gt;
&lt;span class="go"&gt;for l in lines:
&lt;/span&gt;&lt;span class="gp"&gt;    if re.match(r'^paths:', l): inpaths = True;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;continue&lt;/span&gt;
&lt;span class="go"&gt;    if inpaths and re.match(r'^\S', l): break
&lt;/span&gt;&lt;span class="gp"&gt;    m = re.match(r'^  (/\S*):\s*$&lt;/span&gt;&lt;span class="s1"&gt;', l)
&lt;/span&gt;&lt;span class="gp"&gt;    if m: cur = m.group(1);&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;continue
&lt;/span&gt;&lt;span class="gp"&gt;    m2 = re.match(r'^    (get|put|post|delete|head|patch):\s*$&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;, l&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="go"&gt;    if m2 and cur: ops.append('%s %s' % (m2.group(1).upper(), cur))
print("spec operations:", len(ops))
EOF
spec operations: 35
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Thirty-five operations: listing namespaces, loading a table, committing an update, creating a view, planning a scan.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Does a Catalog Say About Itself?
&lt;/h2&gt;

&lt;p&gt;The endpoint every client calls first is &lt;code&gt;GET /v1/config&lt;/code&gt;. It returns the routing prefix for every later request, and it may return one more thing:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;endpoints&lt;/strong&gt;: A list of endpoints that the server supports. The format of each endpoint must be &lt;code&gt;"&amp;lt;HTTP verb&amp;gt; &amp;lt;resource path from OpenAPI REST spec&amp;gt;"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;— &lt;code&gt;rest-catalog-open-api.yaml&lt;/code&gt;, &lt;code&gt;CatalogConfig&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That field is the reason this article exists. A catalog publishes a machine-readable list of what it supports, and nothing stops a client from trusting it.&lt;/p&gt;

&lt;p&gt;Throughout, &lt;strong&gt;declared&lt;/strong&gt; means named in that array and &lt;strong&gt;served&lt;/strong&gt; means returned a 2xx to the probe. Where a catalog declares an endpoint and does not serve it, I call that an &lt;strong&gt;overclaim&lt;/strong&gt;, on the strength of the spec's own word &lt;em&gt;supports&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Suite Does Not Do
&lt;/h2&gt;

&lt;p&gt;Apache ships a REST Compatibility Kit, &lt;code&gt;RESTCompatibilityKitSuite&lt;/code&gt;, which tests a server's behaviour against the Java reference implementation. It is the right tool for "is this catalog correct". It does not compare a server's declaration against what that server serves, which is the axis here. The two are complementary.&lt;/p&gt;

&lt;p&gt;This suite also never checks whether a returned value is &lt;em&gt;right&lt;/em&gt;. It checks whether a field is present.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Harness
&lt;/h2&gt;

&lt;p&gt;Three tiers of evidence, from one identical request suite:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Endpoint tier&lt;/strong&gt; — does the operation exist, and what status comes back&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Field tier&lt;/strong&gt; — 30 specification field paths checked against each &lt;code&gt;loadTable&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Declaration tier&lt;/strong&gt; — the &lt;code&gt;endpoints&lt;/code&gt; array cross-checked against behaviour
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
&lt;/span&gt;&lt;span class="gp"&gt;import sys;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;sys.path.insert(0,'.')
&lt;/span&gt;&lt;span class="go"&gt;from probe import spec
sigs = {p.signature() for p in spec.PROBES + spec.WRITE_PROBES}
print('probes:', len(spec.PROBES) + len(spec.WRITE_PROBES))
print('distinct endpoint signatures:', len(sigs))
"
probes: 33
distinct endpoint signatures: 25
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty-five of the specification's 35 operations, or 71%.&lt;/p&gt;

&lt;p&gt;Two design rules matter for reading the results. Raw request and response are stored for every probe, so a verdict is re-derivable without re-running against a vendor. And a probe whose prerequisite failed is marked not-tested rather than failed — if a catalog refuses to create a namespace, the probes that needed one prove nothing about the endpoints they target.&lt;/p&gt;

&lt;h2&gt;
  
  
  At This Point You Should Have
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Docker, for the control catalog&lt;/li&gt;
&lt;li&gt;Python 3.13 with &lt;code&gt;requests&lt;/code&gt;, &lt;code&gt;pyiceberg&lt;/code&gt; and &lt;code&gt;botocore&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;An account on whichever vendors you intend to probe&lt;/li&gt;
&lt;li&gt;The maximum privilege available on each — every result below was gathered with
&lt;code&gt;roles/owner&lt;/code&gt;, AWS account root, &lt;code&gt;ACCOUNTADMIN&lt;/code&gt;, Databricks account admin,
Fabric workspace Admin, and the Polaris root principal
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
&lt;/span&gt;&lt;span class="go"&gt;import sys, pyiceberg, pyarrow, requests, botocore
print('python     ', sys.version.split()[0])
print('pyiceberg  ', pyiceberg.__version__)
print('pyarrow    ', pyarrow.__version__)
print('requests   ', requests.__version__)
print('botocore   ', botocore.__version__)
"
python      3.13.13
pyiceberg   0.12.0
pyarrow     24.0.0
requests    2.34.2
botocore    1.43.34
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Bringing Up Apache Polaris, the Control
&lt;/h2&gt;

&lt;p&gt;Start with the control, not the clouds. A red cell in a permissively configured reference implementation is almost always your bug, not a specification gap.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; polaris &lt;span class="nt"&gt;-p&lt;/span&gt; 8181:8181 &lt;span class="nt"&gt;-p&lt;/span&gt; 8182:8182 &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="gp"&gt;    --user "$&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;:&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;" &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
&lt;/span&gt;&lt;span class="go"&gt;    -v /etc/passwd:/etc/passwd:ro -v /etc/group:/etc/group:ro \
&lt;/span&gt;&lt;span class="gp"&gt;    -v "$&lt;/span&gt;WH:&lt;span class="nv"&gt;$WH&lt;/span&gt;&lt;span class="s2"&gt;" &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;    -e HADOOP_USER_NAME="$&lt;/span&gt;&lt;span class="s2"&gt;(id -un)"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="go"&gt;    -e POLARIS_BOOTSTRAP_CREDENTIALS=POLARIS,root,s3cr3t \
    -e JAVA_OPTS_APPEND="-Dpolaris.features.\"ALLOW_INSECURE_STORAGE_TYPES\"=true \
       -Dpolaris.features.\"SUPPORTED_CATALOG_STORAGE_TYPES\"=[\"FILE\"] \
       -Dpolaris.readiness.ignore-severe-issues=true \
       -Dpolaris.features.\"DROP_WITH_PURGE_ENABLED\"=true" \
    apache/polaris:latest
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-sf&lt;/span&gt; http://localhost:8182/q/health/ready
&lt;span class="go"&gt;{"status": "UP", "checks": [...]}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four things there are not obvious, and each one cost a failed attempt.&lt;/p&gt;

&lt;p&gt;FILE storage is refused by default, and enabling it needs &lt;strong&gt;both&lt;/strong&gt; &lt;code&gt;ALLOW_INSECURE_STORAGE_TYPES&lt;/code&gt; and &lt;code&gt;SUPPORTED_CATALOG_STORAGE_TYPES&lt;/code&gt;. Enabling it then escalates the production-readiness check from warning to fatal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;docker logs polaris 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A1&lt;/span&gt; &lt;span class="s1"&gt;'Severe production'&lt;/span&gt;
&lt;span class="go"&gt;Caused by: java.lang.IllegalStateException: Severe production readiness issues
detected, startup aborted!
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So &lt;code&gt;polaris.readiness.ignore-severe-issues&lt;/code&gt; is required as well. Third, the container writes table metadata while the client writes data files, so both need the same warehouse path — hence the bind mount at an identical absolute path and &lt;code&gt;--user&lt;/code&gt; so files are owned by you.&lt;/p&gt;

&lt;p&gt;Fourth, and the one that wastes an hour: with &lt;code&gt;--user&lt;/code&gt;, Hadoop's &lt;code&gt;UserGroupInformation&lt;/code&gt; cannot resolve the uid, and the failure surfaces as a 503 that reads like a storage error.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# symptom before mounting /etc/passwd&lt;/span&gt;
&lt;span class="go"&gt;RuntimeIOException: Failed to get file system for path: file:/.../metadata/00000-....json
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mounting &lt;code&gt;/etc/passwd&lt;/code&gt; read-only fixes it. &lt;code&gt;DROP_WITH_PURGE_ENABLED&lt;/code&gt; is on deliberately: with it off, Polaris refuses both a purge drop and &lt;code&gt;dropView&lt;/code&gt;, which would put artificial red cells in the control.&lt;/p&gt;

&lt;p&gt;Then the catalog and its grants:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;TOK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:8181/api/catalog/v1/oauth/tokens &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="go"&gt;    -d grant_type=client_credentials -d client_id=root -d client_secret=s3cr3t \
&lt;/span&gt;&lt;span class="gp"&gt;    -d scope=PRINCIPAL_ROLE:ALL | python3 -c "import sys,json;&lt;/span&gt;print&lt;span class="o"&gt;(&lt;/span&gt;json.load&lt;span class="o"&gt;(&lt;/span&gt;sys.stdin&lt;span class="o"&gt;)[&lt;/span&gt;&lt;span class="s1"&gt;'access_token'&lt;/span&gt;&lt;span class="o"&gt;])&lt;/span&gt;&lt;span class="s2"&gt;")
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;curl -s -o /dev/null -w "&lt;/span&gt;create catalog: HTTP %&lt;span class="o"&gt;{&lt;/span&gt;http_code&lt;span class="o"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;" &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
&lt;/span&gt;&lt;span class="go"&gt;    -X POST http://localhost:8181/api/management/v1/catalogs \
&lt;/span&gt;&lt;span class="gp"&gt;    -H "Authorization: Bearer $&lt;/span&gt;TOK&lt;span class="s2"&gt;" -H "&lt;/span&gt;Content-Type: application/json&lt;span class="s2"&gt;" &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
&lt;/span&gt;&lt;span class="go"&gt;    -d "{\"catalog\":{\"name\":\"quickstart_catalog\",\"type\":\"INTERNAL\", ... }}"
create catalog: HTTP 201
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Bringing Up Google BigLake
&lt;/h2&gt;

&lt;p&gt;The endpoint is &lt;code&gt;https://biglake.googleapis.com/iceberg/v1/restcatalog&lt;/code&gt;. Note &lt;code&gt;v1&lt;/code&gt;, not &lt;code&gt;v1beta&lt;/code&gt; — both resolve and return identical config, but &lt;code&gt;v1&lt;/code&gt; is what the documentation gives.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;gcloud services &lt;span class="nb"&gt;enable &lt;/span&gt;biglake.googleapis.com &lt;span class="nt"&gt;--project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$PROJECT&lt;/span&gt;
&lt;span class="go"&gt;Operation "operations/acat...." finished successfully.
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;gcloud storage buckets create gs://&lt;span class="nv"&gt;$PROJECT&lt;/span&gt;&lt;span class="nt"&gt;-iceberg-probe&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="gp"&gt;    --project=$&lt;/span&gt;PROJECT &lt;span class="nt"&gt;--location&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;us-central1 &lt;span class="nt"&gt;--uniform-bucket-level-access&lt;/span&gt;
&lt;span class="gp"&gt;Creating gs://$&lt;/span&gt;PROJECT-iceberg-probe/...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things are required beyond the URL. The API is enabled under the name &lt;code&gt;biglake.googleapis.com&lt;/code&gt;, but the error calls it the Lakehouse API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://biglake.googleapis.com/iceberg/v1/restcatalog/v1/config?warehouse=gs://&lt;/span&gt;&lt;span class="nv"&gt;$BUCKET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="gp"&gt;    -H "Authorization: Bearer $&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;gcloud auth print-access-token&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"
&lt;/span&gt;&lt;span class="go"&gt;{"error": {"code": 403, "message": "Lakehouse API has not been used in project
... before or it is disabled."}}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And every request needs an &lt;code&gt;x-goog-user-project&lt;/code&gt; header. With both in place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://biglake.googleapis.com/iceberg/v1/restcatalog/v1/config?warehouse=gs://&lt;/span&gt;&lt;span class="nv"&gt;$BUCKET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="gp"&gt;    -H "Authorization: Bearer $&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;gcloud auth print-access-token&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;" &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;    -H "x-goog-user-project: $&lt;/span&gt;&lt;span class="s2"&gt;PROJECT"&lt;/span&gt;
&lt;span class="go"&gt;{"overrides":{"prefix":"projects/GCP_PROJECT_NUMBER/catalogs/GCP_PROJECT-iceberg-probe",
 "catalog_credential_mode":"CREDENTIAL_MODE_END_USER"},"defaults":{...},"endpoints":[...]}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The warehouse is &lt;code&gt;gs://BUCKET&lt;/code&gt; for a single-bucket catalog or &lt;code&gt;bl://projects/PROJECT/catalogs/CATALOG&lt;/code&gt; for a multi-bucket one — &lt;strong&gt;not&lt;/strong&gt; &lt;code&gt;bq://&lt;/code&gt;, which addresses the separate BigQuery federation endpoint.&lt;/p&gt;

&lt;p&gt;Seeding writes data files to GCS with application-default credentials, which are separate from the &lt;code&gt;gcloud&lt;/code&gt; user token. A &lt;code&gt;invalid_rapt&lt;/code&gt; error means &lt;code&gt;gcloud auth application-default login&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bringing Up AWS Glue
&lt;/h2&gt;

&lt;p&gt;The endpoint is &lt;code&gt;https://glue.us-east-1.amazonaws.com/iceberg&lt;/code&gt;, SigV4-signed with signing name &lt;code&gt;glue&lt;/code&gt;, and the warehouse is the account id.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;aws s3api create-bucket &lt;span class="nt"&gt;--bucket&lt;/span&gt; &lt;span class="nv"&gt;$BUCKET&lt;/span&gt; &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;aws glue create-database &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1 &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="gp"&gt;    --database-input "{\"Name\":\"probe_ns\",\"LocationUri\":\"s3://$&lt;/span&gt;BUCKET/probe_ns/&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;aws glue get-databases --region us-east-1 --query 'DatabaseList[].Name' --output text
&lt;/span&gt;&lt;span class="go"&gt;probe_ns
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Glue rejects &lt;code&gt;createTable&lt;/code&gt; without an explicit table location, which every other catalog here infers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# createTable with no location&lt;/span&gt;
&lt;span class="go"&gt;BadRequestException: Location information cannot be null while creating an iceberg table
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Bringing Up AWS S3 Tables
&lt;/h2&gt;

&lt;p&gt;The endpoint is &lt;code&gt;https://s3tables.us-east-1.amazonaws.com/iceberg&lt;/code&gt;, SigV4 with signing name &lt;code&gt;s3tables&lt;/code&gt;, and the warehouse is the table bucket ARN.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;ARN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws s3tables create-table-bucket &lt;span class="nt"&gt;--name&lt;/span&gt; iceberg-probe &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="go"&gt;    --region us-east-1 --query arn --output text)
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$ARN&lt;/span&gt;
&lt;span class="go"&gt;arn:aws:s3tables:us-east-1:AWS_ACCOUNT_ID:bucket/iceberg-probe
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;aws s3tables create-namespace &lt;span class="nt"&gt;--table-bucket-arn&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ARN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="go"&gt;    --namespace probe_ns --region us-east-1
{"tableBucketARN": "...", "namespace": ["probe_ns"]}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two constraints do not appear until you hit them. Namespace names reject uppercase, so a scratch namespace stamped &lt;code&gt;irc_probe_20260903T1626&lt;/code&gt; fails on the &lt;code&gt;T&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# createNamespace with an uppercase character&lt;/span&gt;
&lt;span class="go"&gt;The specified namespace name isn't valid. Specify a different namespace name.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;createTable&lt;/code&gt; requires &lt;code&gt;stage-create&lt;/code&gt; in the body. S3 Tables' managed bucket also rejected &lt;code&gt;pyiceberg&lt;/code&gt;'s default PyArrow writer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# seeding with the default FileIO&lt;/span&gt;
&lt;span class="go"&gt;AWS Error [code 134] during CreateMultipartUpload operation: The authorization
mechanism you have provided is not supported. Please use Signature Version 4.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Switching to &lt;code&gt;pyiceberg.io.fsspec.FsspecFileIO&lt;/code&gt; worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bringing Up Databricks Unity
&lt;/h2&gt;

&lt;p&gt;The endpoint is &lt;code&gt;https://&amp;lt;workspace&amp;gt;.cloud.databricks.com/api/2.1/unity-catalog/iceberg-rest&lt;/code&gt;. Three prerequisites, in order.&lt;/p&gt;

&lt;p&gt;External data access is off by default, per metastore:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOST&lt;/span&gt;&lt;span class="s2"&gt;/api/2.1/unity-catalog/metastore_summary"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="gp"&gt;    | python3 -c "import sys,json;&lt;/span&gt;print&lt;span class="o"&gt;(&lt;/span&gt;json.load&lt;span class="o"&gt;(&lt;/span&gt;sys.stdin&lt;span class="o"&gt;)[&lt;/span&gt;&lt;span class="s1"&gt;'external_access_enabled'&lt;/span&gt;&lt;span class="o"&gt;])&lt;/span&gt;&lt;span class="s2"&gt;"
&lt;/span&gt;&lt;span class="go"&gt;False
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; PATCH &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="gp"&gt;    "$&lt;/span&gt;HOST/api/2.1/unity-catalog/metastores/&lt;span class="nv"&gt;$METASTORE_ID&lt;/span&gt;&lt;span class="s2"&gt;" &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;    -d '{"external_access_enabled": true}' | python3 -c "import sys,json;&lt;/span&gt;&lt;span class="s2"&gt;print(json.load(sys.stdin)['external_access_enabled'])"&lt;/span&gt;
&lt;span class="go"&gt;True
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, the privilege, which must be granted on the &lt;strong&gt;catalog&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# on a schema in the default workspace catalog&lt;/span&gt;
&lt;span class="go"&gt;GRANT EXTERNAL USE SCHEMA ON SCHEMA workspace.probe_ns TO `you@example.com`
[INVALID_PARAMETER_VALUE.PRIVILEGE_NOT_APPLICABLE_TO_ENTITY] Privilege EXTERNAL
USE SCHEMA is not applicable to this entity [workspace.probe_ns:SCHEMA/SCHEMA_DB_STORAGE]

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;GRANT EXTERNAL USE SCHEMA ON CATALOG workspace TO &lt;span class="sb"&gt;`&lt;/span&gt;you@example.com&lt;span class="sb"&gt;`&lt;/span&gt;
&lt;span class="go"&gt;SUCCEEDED
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Third, and the one worth knowing: the Iceberg endpoint requires a token with the &lt;code&gt;all-apis&lt;/code&gt; scope. A PAT scoped to &lt;code&gt;unity-catalog&lt;/code&gt; plus &lt;code&gt;sql&lt;/code&gt; — sufficient for Unity's own REST API and for the SQL statement API — is rejected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$SCOPED_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="gp"&gt;    "$&lt;/span&gt;HOST/api/2.1/unity-catalog/iceberg-rest/v1/config?warehouse&lt;span class="o"&gt;=&lt;/span&gt;workspace&lt;span class="s2"&gt;"
&lt;/span&gt;&lt;span class="go"&gt;{"error_code":403,"message":"Provided access token does not have required scopes: all-apis"}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Probing the open-standard surface needs a broader credential than the vendor's own APIs do.&lt;/p&gt;

&lt;p&gt;Unity also rejects two things during seeding — &lt;code&gt;identifier_field_ids&lt;/code&gt; returns &lt;code&gt;Table with identifier columns is not allowed&lt;/code&gt;, and managed Iceberg tables reject &lt;code&gt;write.delete.mode&lt;/code&gt; with &lt;code&gt;MANAGED_ICEBERG_OPERATION_NOT_SUPPORTED&lt;/code&gt;. Finally, its vended credentials explicitly deny &lt;code&gt;s3:PutObject&lt;/code&gt; on Databricks-managed storage, so an external engine can create a table through the REST catalog but cannot write data files into it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# pyiceberg appending through Unity's vended credentials&lt;/span&gt;
&lt;span class="go"&gt;AWS Error ACCESS_DENIED during CreateMultipartUpload operation: User: ... is not
authorized to perform: s3:PutObject ... with an explicit deny in a resource-based policy
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That table was seeded through Databricks SQL instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bringing Up Snowflake Horizon
&lt;/h2&gt;

&lt;p&gt;The endpoint is &lt;code&gt;https://&amp;lt;account&amp;gt;.snowflakecomputing.com/polaris/api/catalog&lt;/code&gt; — the same path as Open Catalog, because Apache Polaris is embedded in Horizon. The warehouse is a Snowflake &lt;strong&gt;database&lt;/strong&gt; name, not a warehouse.&lt;/p&gt;

&lt;p&gt;Authentication is key-pair, so no account password is handled:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;openssl genrsa &lt;span class="nt"&gt;-out&lt;/span&gt; sf_key.pem 2048
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;openssl rsa &lt;span class="nt"&gt;-in&lt;/span&gt; sf_key.pem &lt;span class="nt"&gt;-pubout&lt;/span&gt; &lt;span class="nt"&gt;-out&lt;/span&gt; sf_key.pub
&lt;span class="go"&gt;writing RSA key
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register the public key once, then everything else runs over the SQL API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;USER&lt;/span&gt; &lt;span class="n"&gt;MYUSER&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;RSA_PUBLIC_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'MIIBIjANBgkq...'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;Statement&lt;/span&gt; &lt;span class="n"&gt;executed&lt;/span&gt; &lt;span class="n"&gt;successfully&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The token exchange differs from stock Polaris in two ways: the scope is &lt;code&gt;session:role:&amp;lt;role&amp;gt;&lt;/code&gt; rather than &lt;code&gt;PRINCIPAL_ROLE:ALL&lt;/code&gt;, and the &lt;code&gt;client_credentials&lt;/code&gt; grant carries a signed JWT in &lt;code&gt;client_secret&lt;/code&gt; with no &lt;code&gt;client_id&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOST&lt;/span&gt;&lt;span class="s2"&gt;/polaris/api/catalog/v1/oauth/tokens"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="go"&gt;    --data-urlencode 'grant_type=client_credentials' \
    --data-urlencode 'scope=session:role:ACCOUNTADMIN' \
&lt;/span&gt;&lt;span class="gp"&gt;    --data-urlencode "client_secret=$&lt;/span&gt;JWT&lt;span class="s2"&gt;" | head -c 60
&lt;/span&gt;&lt;span class="go"&gt;{"access_token":"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Snowflake-managed Iceberg tables need an external volume, which needs an IAM role whose trust policy names values you only get after creating the volume:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;EXTERNAL&lt;/span&gt; &lt;span class="n"&gt;VOLUME&lt;/span&gt; &lt;span class="n"&gt;SF_ICEBERG_PROBE&lt;/span&gt;
  &lt;span class="n"&gt;STORAGE_LOCATIONS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'us-east-1-s3'&lt;/span&gt; &lt;span class="n"&gt;STORAGE_PROVIDER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'S3'&lt;/span&gt;
    &lt;span class="n"&gt;STORAGE_BASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'s3://mybucket/snowflake/'&lt;/span&gt;
    &lt;span class="n"&gt;STORAGE_AWS_ROLE_ARN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'arn:aws:iam::ACCOUNT:role/snowflake-iceberg-probe'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="n"&gt;ALLOW_WRITES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;TRUE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;SF_ICEBERG_PROBE&lt;/span&gt; &lt;span class="n"&gt;successfully&lt;/span&gt; &lt;span class="n"&gt;created&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;

&lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;EXTERNAL&lt;/span&gt; &lt;span class="n"&gt;VOLUME&lt;/span&gt; &lt;span class="n"&gt;SF_ICEBERG_PROBE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;STORAGE_AWS_IAM_USER_ARN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;arn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;aws&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;::...:&lt;/span&gt;&lt;span class="k"&gt;user&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="n"&gt;STORAGE_AWS_EXTERNAL_ID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="n"&gt;_SFCRole&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Update the role's trust policy with those two values, then verify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;SYSTEM&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;VERIFY_EXTERNAL_VOLUME&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SF_ICEBERG_PROBE'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;"success"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;"writeResult"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;"PASSED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;"readResult"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;"PASSED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="nv"&gt;"listResult"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;"PASSED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;"deleteResult"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;"PASSED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="nv"&gt;"awsRoleArnValidationResult"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;"PASSED"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two more. Snowflake rejects &lt;code&gt;TIMESTAMP_TZ(9)&lt;/code&gt; for Iceberg tables and wants &lt;code&gt;TIMESTAMP_LTZ(6)&lt;/code&gt;. And creating a table through the REST catalog requires the database to carry a default external volume, or &lt;code&gt;createTable&lt;/code&gt; returns 403 and four downstream probes fail for reasons unrelated to the endpoints they target:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="n"&gt;PROBE_DB&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;EXTERNAL_VOLUME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'SF_ICEBERG_PROBE'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;Statement&lt;/span&gt; &lt;span class="n"&gt;executed&lt;/span&gt; &lt;span class="n"&gt;successfully&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Bringing Up Microsoft OneLake
&lt;/h2&gt;

&lt;p&gt;The endpoint is &lt;code&gt;https://onelake.table.fabric.microsoft.com/iceberg&lt;/code&gt;. Warehouse and prefix are both &lt;code&gt;&amp;lt;workspaceId&amp;gt;/&amp;lt;dataItemId&amp;gt;&lt;/code&gt;, and the bearer token audience is &lt;code&gt;https://storage.azure.com&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The gate here is licensing, not configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;FT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;az account get-access-token &lt;span class="nt"&gt;--resource&lt;/span&gt; https://api.fabric.microsoft.com &lt;span class="nt"&gt;--query&lt;/span&gt; accessToken &lt;span class="nt"&gt;-o&lt;/span&gt; tsv&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$FT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://api.fabric.microsoft.com/v1/workspaces
&lt;span class="go"&gt;{"requestId":"...","errorCode":"UserNotLicensed","message":"User is not licensed","isRetriable":false}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a personal Microsoft account, and no configuration changes it. On a work account, with the free 60-day trial capacity activated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$FT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://api.fabric.microsoft.com/v1/capacities &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="go"&gt;    | python3 -c "
import sys,json
for c in json.load(sys.stdin)['value']:
    print(c['displayName'], c['sku'], c['state'], c['region'])"
Trial-...  FTL4  Active  East US 2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then a workspace on that capacity, and a lakehouse in it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$FT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$API&lt;/span&gt;&lt;span class="s2"&gt;/workspaces"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="gp"&gt;    -d '{"displayName":"iceberg-probe-ws"}' | python3 -c "import sys,json;&lt;/span&gt;print&lt;span class="o"&gt;(&lt;/span&gt;json.load&lt;span class="o"&gt;(&lt;/span&gt;sys.stdin&lt;span class="o"&gt;)[&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="o"&gt;])&lt;/span&gt;&lt;span class="s2"&gt;"
&lt;/span&gt;&lt;span class="go"&gt;FABRIC_WORKSPACE_ID
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"assign capacity: HTTP %{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="gp"&gt;    -H "Authorization: Bearer $&lt;/span&gt;FT&lt;span class="s2"&gt;" "&lt;/span&gt;&lt;span class="nv"&gt;$API&lt;/span&gt;/workspaces/&lt;span class="nv"&gt;$WS&lt;/span&gt;/assignToCapacity&lt;span class="s2"&gt;" -d "&lt;/span&gt;&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;capacityId&lt;span class="se"&gt;\"&lt;/span&gt;:&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$CAP&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"
&lt;/span&gt;&lt;span class="go"&gt;assign capacity: HTTP 202
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$FT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$API&lt;/span&gt;&lt;span class="s2"&gt;/workspaces/&lt;/span&gt;&lt;span class="nv"&gt;$WS&lt;/span&gt;&lt;span class="s2"&gt;/lakehouses"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="gp"&gt;    -d '{"displayName":"probe_lh"}' | python3 -c "import sys,json;&lt;/span&gt;print&lt;span class="o"&gt;(&lt;/span&gt;json.load&lt;span class="o"&gt;(&lt;/span&gt;sys.stdin&lt;span class="o"&gt;)[&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="o"&gt;])&lt;/span&gt;&lt;span class="s2"&gt;"
&lt;/span&gt;&lt;span class="go"&gt;FABRIC_LAKEHOUSE_ID
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The table is loaded from CSV through the Fabric load-table API and stored as Delta. OneLake exposes it through the Iceberg endpoint by virtualisation — its properties carry &lt;code&gt;XTABLE_METADATA&lt;/code&gt; with &lt;code&gt;"sourceTableFormat":"DELTA"&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Vendor Summary
&lt;/h2&gt;

&lt;p&gt;Read and write surfaces are scored separately and deliberately not summed. A catalog that is read-only by design scores zero on writes, and folding that into one number makes a deliberate design read as a broken implementation.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Catalog&lt;/th&gt;
&lt;th&gt;Read probes&lt;/th&gt;
&lt;th&gt;Write probes&lt;/th&gt;
&lt;th&gt;Not tested&lt;/th&gt;
&lt;th&gt;Declares&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;🥇 Apache Polaris 1.7.0&lt;/td&gt;
&lt;td&gt;15/16&lt;/td&gt;
&lt;td&gt;16/17&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;36&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🥈 Databricks Unity&lt;/td&gt;
&lt;td&gt;12/14&lt;/td&gt;
&lt;td&gt;10/17&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🥉 Snowflake Horizon&lt;/td&gt;
&lt;td&gt;12/14&lt;/td&gt;
&lt;td&gt;7/14&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google BigLake&lt;/td&gt;
&lt;td&gt;11/14&lt;/td&gt;
&lt;td&gt;10/14&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Microsoft OneLake&lt;/td&gt;
&lt;td&gt;11/15&lt;/td&gt;
&lt;td&gt;0/14&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS Glue&lt;/td&gt;
&lt;td&gt;9/15&lt;/td&gt;
&lt;td&gt;10/17&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS S3 Tables&lt;/td&gt;
&lt;td&gt;9/15&lt;/td&gt;
&lt;td&gt;10/17&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Denominators differ because probes that could not be tested are excluded rather than counted as failures. The not-tested column makes each one reconstructable.&lt;/p&gt;

&lt;p&gt;Two of seven publish no &lt;code&gt;endpoints&lt;/code&gt; list at all, so a client cannot discover their capabilities without probing. That is not a specification violation — the field is optional — but it means capability discovery is unavailable on both AWS catalogs.&lt;/p&gt;

&lt;p&gt;OneLake's read surface is mid-pack rather than last. Its zero on writes is its documented design.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Prefix Matters
&lt;/h2&gt;

&lt;p&gt;Each catalog's routing prefix, as its own &lt;code&gt;/v1/config&lt;/code&gt; returns it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Catalog&lt;/th&gt;
&lt;th&gt;Prefix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Polaris&lt;/td&gt;
&lt;td&gt;&lt;code&gt;quickstart_catalog&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Glue&lt;/td&gt;
&lt;td&gt;&lt;code&gt;catalogs/AWS_ACCOUNT_ID&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3 Tables&lt;/td&gt;
&lt;td&gt;&lt;code&gt;arn%3Aaws%3As3tables%3A...%3Abucket%2Ficeberg-probe&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unity&lt;/td&gt;
&lt;td&gt;&lt;code&gt;catalogs/workspace&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BigLake&lt;/td&gt;
&lt;td&gt;&lt;code&gt;projects/GCP_PROJECT_NUMBER/catalogs/GCP_PROJECT-iceberg-probe&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OneLake&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;workspaceId&amp;gt;/&amp;lt;lakehouseId&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Horizon&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PROBE_DB&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A prefix is not a single path segment. Google returns four, Unity and OneLake return two, and S3 Tables returns a percent-encoded ARN. A client that treats the prefix as one segment, or that re-encodes it, produces URLs the server does not route. I made both mistakes while building this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Views Are Declared Widely and Implemented Once
&lt;/h2&gt;

&lt;p&gt;The specification has six view operations. Probing only &lt;code&gt;listViews&lt;/code&gt; cannot distinguish "views are unimplemented" from "list is unimplemented", so all six are probed.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Catalog&lt;/th&gt;
&lt;th&gt;createView, loadView, viewExists, replaceView, renameView, dropView&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;✅ Polaris&lt;/td&gt;
&lt;td&gt;all six served&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;❌ Glue&lt;/td&gt;
&lt;td&gt;406 — &lt;code&gt;ListViews endpoint is not supported for Glue Catalog&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;❌ S3 Tables&lt;/td&gt;
&lt;td&gt;404 &lt;code&gt;UnknownOperationException&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;❌ Unity&lt;/td&gt;
&lt;td&gt;404 &lt;code&gt;ENDPOINT_NOT_FOUND&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;❌ BigLake&lt;/td&gt;
&lt;td&gt;404 &lt;code&gt;Method not found&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;❌ OneLake&lt;/td&gt;
&lt;td&gt;404 and 405&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;❌ Horizon&lt;/td&gt;
&lt;td&gt;403 &lt;code&gt;Authorization failed&lt;/code&gt;, as ACCOUNTADMIN&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Horizon declares seven view endpoints and serves none of them. Both Horizon and Unity were re-tested with a real native view present in the namespace, in case the failures were an empty-namespace artefact:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# a native view exists in both, created through each vendor's own SQL&lt;/span&gt;
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$SFT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SF&lt;/span&gt;&lt;span class="s2"&gt;/v1/PROBE_DB/namespaces/PROBE_NS/views"&lt;/span&gt;
&lt;span class="go"&gt;{"error":{"message":"Authorization failed","type":"ForbiddenException","code":403}}

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$DBT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DBX&lt;/span&gt;&lt;span class="s2"&gt;/v1/catalogs/workspace/namespaces/probe_ns/views"&lt;/span&gt;
&lt;span class="go"&gt;{"error_code":"ENDPOINT_NOT_FOUND","message":"No API found for 'GET .../views'"}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Neither is a fixture problem, and ACCOUNTADMIN is the highest role in a Snowflake account.&lt;/p&gt;

&lt;h2&gt;
  
  
  Glue Answers Unrouted Operations With HTTP 200
&lt;/h2&gt;

&lt;p&gt;Three probes against Glue return &lt;code&gt;200 OK&lt;/code&gt; carrying an exception:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# POST .../tables/{table}/metrics against Glue&lt;/span&gt;
&lt;span class="go"&gt;HTTP 200
&lt;/span&gt;&lt;span class="gp"&gt;{"Output": {"__type": "com.amazon.coral.service#&lt;/span&gt;UnknownOperationException&lt;span class="s2"&gt;"}, "&lt;/span&gt;Version&lt;span class="s2"&gt;": "&lt;/span&gt;1.0&lt;span class="s2"&gt;"}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mechanism is the AWS protocol layer answering an operation its front door does not route, rather than Glue returning a broken success for an implemented endpoint. The consequence for a client is the same either way: code that checks status codes sees three endpoints that work. The harness scores these with their own verdict rather than as &lt;code&gt;OK&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Glue's config also advertises &lt;code&gt;rest-table-scan-enabled: true&lt;/code&gt;, and scan planning is one of the three.&lt;/p&gt;

&lt;h2&gt;
  
  
  Eleven Overclaims Across Four Vendors
&lt;/h2&gt;

&lt;p&gt;Endpoints named in a catalog's own &lt;code&gt;endpoints&lt;/code&gt; array that did not serve:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Catalog&lt;/th&gt;
&lt;th&gt;Overclaimed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Microsoft OneLake&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;create_namespace&lt;/code&gt;, &lt;code&gt;create_table&lt;/code&gt;, &lt;code&gt;commit_table&lt;/code&gt;, &lt;code&gt;rename_table&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Snowflake Horizon&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;list_views&lt;/code&gt;, &lt;code&gt;create_view&lt;/code&gt;, &lt;code&gt;update_namespace_props&lt;/code&gt;, &lt;code&gt;commit_transaction&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Databricks Unity&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;plan_table_scan&lt;/code&gt;, &lt;code&gt;update_namespace_props&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google BigLake&lt;/td&gt;
&lt;td&gt;&lt;code&gt;load_credentials&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Apache Polaris&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS Glue, AWS S3 Tables&lt;/td&gt;
&lt;td&gt;not applicable — declare nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Only the open-source control declares honestly. Two catalogs cannot be judged on this axis at all.&lt;/p&gt;

&lt;p&gt;Two of these deserve their exact wording. Unity's &lt;code&gt;update_namespace_props&lt;/code&gt; requires a field the specification does not define:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# POST .../namespaces/{namespace}/properties against Unity&lt;/span&gt;
&lt;span class="go"&gt;HTTP 400
Malformed request: INVALID_PARAMETER_VALUE: Etag token version is missing
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The specification's &lt;code&gt;UpdateProperties&lt;/code&gt; request has no etag field, so a conformant client cannot call this endpoint at all.&lt;/p&gt;

&lt;p&gt;And OneLake's documentation and its declaration are wrong in opposite directions. Microsoft documents the endpoint as read-only, which matches the behaviour — every write returns &lt;code&gt;Requested Api is not found&lt;/code&gt; — but &lt;code&gt;/v1/config&lt;/code&gt; declares four write endpoints anyway. Meanwhile Microsoft documents that the &lt;code&gt;parent&lt;/code&gt; query parameter on &lt;code&gt;listNamespaces&lt;/code&gt; is not supported, and measured, it works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$ST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OL&lt;/span&gt;&lt;span class="s2"&gt;/v1/&lt;/span&gt;&lt;span class="nv"&gt;$PREFIX&lt;/span&gt;&lt;span class="s2"&gt;/namespaces?parent=dbo"&lt;/span&gt;
&lt;span class="go"&gt;{"namespaces":[],"next-page-token":null}
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$ST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OL&lt;/span&gt;&lt;span class="s2"&gt;/v1/&lt;/span&gt;&lt;span class="nv"&gt;$PREFIX&lt;/span&gt;&lt;span class="s2"&gt;/namespaces?parent=zzz"&lt;/span&gt;
&lt;span class="go"&gt;{"error":{"message":"The given namespace does not exist","type":"NoSuchNamespaceException"...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An empty list for a namespace with no children, and a typed error for one that does not exist. That is the parameter working.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Commit Path Has Depth the Endpoint List Does Not Show
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;POST .../tables/{table}&lt;/code&gt; is one endpoint carrying 25 distinct update actions. Five are probed separately. Polaris, Glue, S3 Tables, Unity and BigLake accept all five. Horizon accepts two:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;Horizon&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;✅ set-properties&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;✅ add-schema with set-current-schema:-1&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;❌ remove-properties&lt;/td&gt;
&lt;td&gt;409 or 500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;❌ set-current-schema, standalone&lt;/td&gt;
&lt;td&gt;409 or 500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;❌ upgrade-format-version&lt;/td&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last is an explicit refusal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# upgrade-format-version to 2 against Horizon&lt;/span&gt;
&lt;span class="go"&gt;HTTP 400
Upgrading the Iceberg format version of an existing table is not allowed for
Horizon accounts
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two showing two status codes are showing observed nondeterminism. Across back-to-back sweeps both alternate between &lt;code&gt;409 CONFLICT&lt;/code&gt; and &lt;code&gt;500 SERVER_ERROR&lt;/code&gt; on identical input:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# two consecutive full sweeps, diffed&lt;/span&gt;
&lt;span class="gp"&gt;snowflake-horizon commit_remove_properties:  CONFLICT     -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;SERVER_ERROR
&lt;span class="gp"&gt;snowflake-horizon commit_set_current_schema: SERVER_ERROR -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;CONFLICT
&lt;span class="go"&gt;differences across two sweeps: 2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 500 carries &lt;code&gt;UNEXPECTED_ERROR_SIGNALED ... Indeterminate result during conflict resolution check&lt;/code&gt;. Both fail either way, so the scores are stable and the status code is not. Two probes on the same conflict-resolution path behaving this way makes it a property of that path rather than a single flaky request. Every other cell in every catalog was stable across six sweeps.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Does Not Differ
&lt;/h2&gt;

&lt;p&gt;The suite also checks 30 field paths in each &lt;code&gt;loadTable&lt;/code&gt; response — schemas, column IDs, partition specs, sort orders, snapshot history, refs, statistics. Twenty-four of the thirty are identical across all seven catalogs.&lt;/p&gt;

&lt;p&gt;Whatever separates these implementations, it is not the fidelity of &lt;code&gt;loadTable&lt;/code&gt;. Of the six rows that do differ, one is caused by the fixture rather than the catalog, two reflect storage and credential-vending configuration, and two are the difference between rendering "no statistics" as an empty list and omitting the key.&lt;/p&gt;

&lt;p&gt;This is a null result and it is worth stating plainly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Smaller: Purge Semantics Are Per-Catalog
&lt;/h2&gt;

&lt;p&gt;Dropping a table is not uniform. Polaris refuses &lt;code&gt;purgeRequested=true&lt;/code&gt; unless &lt;code&gt;DROP_WITH_PURGE_ENABLED&lt;/code&gt; is set, Glue refuses it outright, S3 Tables refuses a plain drop, and BigLake, Unity and Horizon accept the purge form.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# DELETE .../tables/{table}?purgeRequested=true against Glue&lt;/span&gt;
&lt;span class="go"&gt;HTTP 400  PurgeRequested cannot be true for Glue iceberg tables.
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# DELETE .../tables/{table} against S3 Tables&lt;/span&gt;
&lt;span class="go"&gt;HTTP 400  DropTable operation failed. S3 Tables only supports dropping tables with purge enabled.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is worth knowing when writing a client, but it is the weakest result here: these are separate products with different storage models, and a client configures a catalog once rather than swapping between them. I mention it because it is measured, not because it is important.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The goal of this article was to measure what seven Iceberg REST catalog implementations actually serve, rather than what their documentation claims. The key to the solution was probing the &lt;code&gt;endpoints&lt;/code&gt; array each catalog publishes about itself and comparing it against behaviour on the same request suite. The conformance results were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Eleven overclaims across four vendors.&lt;/strong&gt; Endpoints a catalog names in its own
&lt;code&gt;/v1/config&lt;/code&gt; and then does not serve. Only the open-source control, Apache
Polaris, declares honestly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two of seven publish no declaration at all.&lt;/strong&gt; Glue and S3 Tables leave a
client no way to discover their surface short of probing it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Views are effectively a Polaris-only feature.&lt;/strong&gt; Six operations, six managed
catalogs, zero implementations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Supports the REST catalog" is true and not very informative.&lt;/strong&gt; All seven
served the core read path. The divergence is in the write surface, in the update
actions inside one endpoint, and in operational semantics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;loadTable&lt;/code&gt; fidelity is not a differentiator.&lt;/strong&gt; Twenty-four of thirty checked
field paths are identical everywhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scope: seven catalogs, each probed once in a single region on 2026-09-03 against one table shape, covering 25 of the specification's 35 operations, 5 of 25 update actions and 1 of 8 table requirements; Polaris pinned to 1.7.0 locally with non-default permissive flags, BigLake in us-central1, Glue and S3 Tables in us-east-1, Unity in us-west-2, Horizon on AWS us-east-1 and OneLake in East US 2; Unity, Horizon and OneLake on trial accounts, which is a genuine confound because one Horizon refusal is worded "not allowed for Horizon accounts"; the six managed catalogs expose no version, so no result can be tied to a release; fixtures are identical on five of the seven, with the other two differing because those catalogs refuse part of the seed; and the field tier checks that a value is present, never that it is correct.&lt;/p&gt;

&lt;p&gt;The strategy for using a single request suite across seven vendor catalogs was validated with an incremental step by step approach.&lt;/p&gt;

</description>
      <category>iceberg</category>
      <category>lakehouse</category>
      <category>dataengineering</category>
      <category>googlecloud</category>
    </item>
    <item>
      <title>ChromeOS Lookalikes, Two Ways: One With Drivers, One Without</title>
      <dc:creator>xbill</dc:creator>
      <pubDate>Thu, 03 Sep 2026 15:18:58 +0000</pubDate>
      <link>https://dev.to/gde/chromeos-lookalikes-two-ways-one-with-drivers-one-without-83m</link>
      <guid>https://dev.to/gde/chromeos-lookalikes-two-ways-one-with-drivers-one-without-83m</guid>
      <description>&lt;p&gt;This article covers two scripts that live in the same repository for the same underlying reason — getting a Linux box to behave like a Chromebook — and that end up doing almost nothing alike, because they solve that problem for two different machines.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/xbill9/chromeos-boot" rel="noopener noreferrer"&gt;https://github.com/xbill9/chromeos-boot&lt;/a&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  Two Scripts, One Theme
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;chromeos-boot&lt;/code&gt; is not one tool with two modes. It is two standalone scripts that happen to share a repo and a name:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;stage&lt;/code&gt;&lt;/strong&gt; — brings a fresh ChromeOS Linux (Crostini) &lt;em&gt;container&lt;/em&gt; up from nothing, when everything else it needs lives in a private Cloud Storage bucket.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;flex&lt;/code&gt;&lt;/strong&gt; — turns a stock Debian &lt;em&gt;desktop&lt;/em&gt; into a ChromeOS Flex lookalike: shelf, web apps, keybindings, wallpaper. No bucket, no gcloud, nothing private.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;stage&lt;/code&gt; gets a machine to where it can read the bucket. &lt;code&gt;flex&lt;/code&gt; makes a machine look like the thing it is imitating — and that word "imitating" only applies to one of them. &lt;code&gt;stage&lt;/code&gt; runs inside an actual Crostini container, on actual ChromeOS; it has nothing to fake. &lt;code&gt;flex&lt;/code&gt; runs on a desktop that gave up ChromeOS entirely, so everything about the look is manufactured. Two ChromeOS lookalikes, and only one of them is actually pretending.&lt;/p&gt;

&lt;h4&gt;
  
  
  Why Two, Not One
&lt;/h4&gt;

&lt;p&gt;The reason there's a machine running &lt;code&gt;flex&lt;/code&gt; at all is that Crostini's Linux environment cannot load the NVIDIA driver, and that's a kernel problem before it's a permissions one.&lt;/p&gt;

&lt;p&gt;Crostini's container doesn't run on the ChromeOS kernel directly. Enabling Linux spins up a lightweight VM (Termina) with its own guest kernel, one that Google builds, signs, and ships as part of the OS image — and your Debian container runs inside that VM, sharing its kernel rather than bringing one of its own. The NVIDIA driver isn't a userspace package; the installer's whole job is to compile &lt;code&gt;nvidia.ko&lt;/code&gt; against the exact kernel that's running and load it with &lt;code&gt;insmod&lt;/code&gt;. Termina's kernel has no headers shipped for that build, no writable path to drop a module into &lt;code&gt;/lib/modules&lt;/code&gt; that survives the next ChromeOS update, and no interest in accepting an unsigned out-of-tree module on a kernel that's otherwise locked down by verified boot.&lt;/p&gt;

&lt;p&gt;Even past the module problem, there's no card to attach it to. Graphics inside Crostini go through virtio-gpu — the VM sees a paravirtualized display device good enough for compositing and OpenGL/Vulkan passthrough, not the raw PCI device a discrete GPU actually is. The NVIDIA driver wants to bind directly to that PCI device. Crostini never hands it one.&lt;/p&gt;

&lt;p&gt;So the fix was to stop going through ChromeOS for this at all: put Debian directly on the laptop's metal, where &lt;code&gt;apt install nvidia-driver&lt;/code&gt; is building against a kernel you actually control, against a GPU that's actually visible on the PCI bus. That solves the driver problem and creates the one &lt;code&gt;flex&lt;/code&gt; exists to solve — a stock Debian/GNOME desktop looks and behaves nothing like ChromeOS, and none of what came free inside Crostini comes free here. &lt;code&gt;flex&lt;/code&gt; puts the shelf, the Google web apps, the keybindings, and the wallpaper back by hand.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;stage&lt;/code&gt; and &lt;code&gt;flex&lt;/code&gt; are answers to two different questions, not two takes on one. &lt;code&gt;stage&lt;/code&gt; is what you run &lt;em&gt;because&lt;/em&gt; you're still inside ChromeOS. &lt;code&gt;flex&lt;/code&gt; is what you run &lt;em&gt;because&lt;/em&gt; the kernel made you leave.&lt;/p&gt;

&lt;h4&gt;
  
  
  stage: Staging a Fresh Crostini Container
&lt;/h4&gt;

&lt;p&gt;&lt;strong&gt;At this point you should have&lt;/strong&gt; a brand-new Crostini container — the default state after enabling Linux on a Chromebook — and nothing else. No &lt;code&gt;git&lt;/code&gt;, no &lt;code&gt;gcloud&lt;/code&gt;, nothing installed.&lt;/p&gt;

&lt;p&gt;That is a chicken-and-egg problem: reading the private bucket needs &lt;code&gt;gcloud&lt;/code&gt;, and a bare container has no &lt;code&gt;gcloud&lt;/code&gt;. &lt;code&gt;stage&lt;/code&gt; is the one piece that has to be fetchable without credentials, so it is the only thing in this workflow that lives outside the bucket, pasted in or pulled straight from GitHub:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bash &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;curl &lt;span class="nt"&gt;-sSL&lt;/span&gt; https://raw.githubusercontent.com/xbill9/chromeos-boot/main/stage&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Process substitution rather than a pipe is deliberate. &lt;code&gt;curl ... | bash&lt;/code&gt; hands the script to bash on &lt;strong&gt;stdin&lt;/strong&gt;, which is the same stdin &lt;code&gt;gcloud auth login&lt;/code&gt; needs to read answers from; the login then fails or silently eats the rest of the script. &lt;code&gt;bash &amp;lt;(curl ...)&lt;/code&gt; passes it as a file descriptor and leaves stdin attached to the terminal.&lt;/p&gt;

&lt;p&gt;What it does, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Installs the Google Cloud CLI from the tarball into &lt;code&gt;$HOME&lt;/code&gt; — no sudo, no apt, no keyring.&lt;/li&gt;
&lt;li&gt;Logs in, opening a browser tab. Crostini hands that URL to the ChromeOS browser, which is already signed in, so this is usually two clicks.&lt;/li&gt;
&lt;li&gt;Copies &lt;code&gt;nnn&lt;/code&gt; out of the bucket into &lt;code&gt;~/bin&lt;/code&gt; and runs it, which fetches everything else — scripts, dotfiles.&lt;/li&gt;
&lt;li&gt;Replaces the tarball with the apt-managed &lt;code&gt;google-cloud-cli&lt;/code&gt; in &lt;code&gt;/usr/bin&lt;/code&gt;, then deletes &lt;code&gt;~/google-cloud-sdk&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 1's tarball is not the copy meant to stick around — it exists only to read the bucket once, before apt is reachable. Step 4 swaps it for the package apt will keep current, and is non-fatal: without sudo, or with apt unreachable, &lt;code&gt;stage&lt;/code&gt; warns and leaves the tarball in place rather than failing the run.&lt;/p&gt;

&lt;p&gt;Four commands finish the job, the last two coming from the &lt;code&gt;.bashrc&lt;/code&gt; that step 3 just fetched:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bash &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;curl &lt;span class="nt"&gt;-sSL&lt;/span&gt; https://raw.githubusercontent.com/xbill9/chromeos-boot/main/stage&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;exec &lt;/span&gt;bash &lt;span class="nt"&gt;-l&lt;/span&gt;
bootstrap
bootstrap code
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;stage&lt;/code&gt; never asks what kind of hardware it is running on, because it doesn't need to know. Everything it touches — the bucket, &lt;code&gt;gcloud&lt;/code&gt;, the dotfiles — is the same regardless of the laptop underneath. That is the shape of a script solving a &lt;em&gt;credentials&lt;/em&gt; problem, not a &lt;em&gt;hardware&lt;/em&gt; one.&lt;/p&gt;

&lt;h4&gt;
  
  
  flex: Skinning a Stock Debian Desktop
&lt;/h4&gt;

&lt;p&gt;&lt;strong&gt;At this point you should have&lt;/strong&gt; Debian 13 (trixie) with GNOME 48, installed on real hardware — not a container. &lt;code&gt;flex&lt;/code&gt; is written from, and matches, the customization actually run on a Yoga 9 with an NVIDIA GPU.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;bash flex &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;span class="go"&gt;pkgs
theme
icons
shelf
webapps
appgrid
look
keys
helpers
wallpaper
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ten stages, run in that order by default, or individually to repair one without redoing the rest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;bash flex &lt;span class="nt"&gt;-h&lt;/span&gt;
&lt;span class="go"&gt;
Turn a stock Debian 13 (trixie) / GNOME 48 desktop into a ChromeOS Flex
lookalike.

Companion to `stage`.  `stage` gets a machine to where it can read the
&lt;/span&gt;&lt;span class="gp"&gt;private bucket;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="sb"&gt;`&lt;/span&gt;flex&lt;span class="sb"&gt;`&lt;/span&gt; makes the machine look like the thing it is imitating:
&lt;span class="go"&gt;a bottom shelf, the Google web apps as first-class icons, ChromeOS
keybindings, a pruned app grid, and a matching pair of wallpapers.

usage: bash flex [--light|--dark]        everything (dark unless told otherwise)
       bash flex -l                      list the stages
&lt;/span&gt;&lt;span class="gp"&gt;       bash flex &amp;lt;stage&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&amp;lt;stage&amp;gt;...]    run only those
&lt;span class="go"&gt;       bash flex revert                  undo it
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing here needs root except &lt;code&gt;pkgs&lt;/code&gt; (apt, non-fatal without sudo), and everything it creates lives under &lt;code&gt;$HOME&lt;/code&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  The Ten Stages, Briefly
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pkgs&lt;/code&gt;&lt;/strong&gt; — Roboto, gnome-tweaks, and Chrome if no Chromium-family browser is already there. The only stage that touches apt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;theme&lt;/code&gt;&lt;/strong&gt; — adw-gtk3, light and dark, from the upstream tarball (trixie ships no package for it), so GTK3 apps match the libadwaita GTK4 ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;icons&lt;/code&gt;&lt;/strong&gt; — Papirus and Papirus-Dark, user-level. ~60MB, the slower of the two heavyweight stages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;shelf&lt;/code&gt;&lt;/strong&gt; — installs dash-to-panel in place of the packaged dash-to-dock, because only dash-to-panel merges the taskbar and system tray into one bar — the ChromeOS shelf is one bar, not two. Bottom, 56px, 75% opacity, Google-Blue running-app dots, Alt+1–9 for the first nine pinned apps. The per-monitor layout is resolved through Mutter's &lt;code&gt;DisplayConfig&lt;/code&gt; at run time rather than a hardcoded panel ID, so the same script works on a laptop panel and an external monitor without editing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;webapps&lt;/code&gt;&lt;/strong&gt; — the nine Google apps (Gmail, Calendar, Drive, Docs, Sheets, Keep, Photos, Maps, YouTube) as windowless &lt;code&gt;--app=&lt;/code&gt; launchers, each with its own shelf icon fetched from gstatic with a favicon-service fallback.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;appgrid&lt;/code&gt;&lt;/strong&gt; — hides the apps ChromeOS doesn't have (LibreOffice, xterm, Disk Utility, and 30-odd others) by shadowing each system &lt;code&gt;.desktop&lt;/code&gt; file with a copy carrying &lt;code&gt;NoDisplay=true&lt;/code&gt;. A copy, not a stub, so MIME associations and "Open with" keep working. Nothing is uninstalled.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;look&lt;/code&gt;&lt;/strong&gt; — Roboto as the UI/document/titlebar font, blue accent, no hot corners, time-only clock, one workspace, shelf favorites pinned.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;keys&lt;/code&gt;&lt;/strong&gt; — Caps Lock becomes Super, the ChromeOS Launcher key. Alt handles window minimize/maximize/tile; Alt+Tab cycles windows rather than app groups (which move to Super+Tab); Ctrl+F5 is overview; Ctrl+Shift+F5 is screenshot; Ctrl+Shift+Q signs out; Ctrl+Alt+T opens a terminal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;helpers&lt;/code&gt;&lt;/strong&gt; — installs &lt;code&gt;set-mode.sh&lt;/code&gt;, &lt;code&gt;gen_wallpaper.py&lt;/code&gt;, and &lt;code&gt;boot-splash.sh&lt;/code&gt; under &lt;code&gt;~/.local/share/chromeos-flex/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;wallpaper&lt;/code&gt;&lt;/strong&gt; — renders both wallpaper variants with a hand-rolled PNG encoder (no PIL, no numpy on a stock desktop) — a diagonal gradient, four Google-palette blobs with a quadratic falloff, and an ordered dither to stop a 2560px gradient banding.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Wayland can't restart &lt;code&gt;gnome-shell&lt;/code&gt; in place, so the shelf, the GTK theme, and the app grid only take effect after a full log out and back in. The wallpaper and the keybindings are live immediately.&lt;/p&gt;

&lt;h4&gt;
  
  
  Flipping Light and Dark Without Rerunning Everything
&lt;/h4&gt;

&lt;p&gt;The look is roughly fifteen &lt;code&gt;gsettings&lt;/code&gt; calls that only make sense moving together — color scheme, GTK theme, icon theme, both wallpaper keys, the lock screen, and eight dash-to-panel colors. &lt;code&gt;helpers&lt;/code&gt; installs &lt;code&gt;set-mode.sh&lt;/code&gt; once so that switching modes afterward doesn't mean rerunning the whole script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;~/.local/share/chromeos-flex/set-mode.sh light
&lt;span class="go"&gt;switched to light
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;~/.local/share/chromeos-flex/set-mode.sh
&lt;span class="go"&gt;current: 'prefer-light'
  gtk-theme  'adw-gtk3'
  icon-theme 'Papirus'
  wallpaper  'file:///home/xbill/.local/share/backgrounds/chromeos-element-light.png'
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  boot-splash.sh: Installed, Not Run
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;helpers&lt;/code&gt; writes one more file: &lt;code&gt;boot-splash.sh&lt;/code&gt;, which edits &lt;code&gt;/etc/default/grub&lt;/code&gt; to kill the boot menu and hand off to the Plymouth splash trixie already ships. &lt;code&gt;flex&lt;/code&gt; drops it on disk and stops — it's the only piece of either script that touches the boot chain, it needs root, and GRUB is not something to hand to a stage list without reading it first.&lt;/p&gt;

&lt;p&gt;It's not really a speed fix. On the machine it was written for, boot ran about 44 seconds, and roughly 30 of that was firmware and the boot loader before GRUB even started — killing the GRUB timeout only claws back the last 5 seconds or so. What it actually buys is continuity: no text menu flashing past on the way to the desktop, same as an actual Chromebook.&lt;/p&gt;

&lt;h4&gt;
  
  
  Where They're Alike
&lt;/h4&gt;

&lt;p&gt;Different problems, same habits. Both scripts:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Stages&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Broken into named, independently runnable steps (&lt;code&gt;bootstrap &amp;lt;stage&amp;gt;&lt;/code&gt; / &lt;code&gt;bash flex &amp;lt;stage&amp;gt;&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Idempotent&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Re-running is how a half-finished run gets repaired, not something to avoid&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Listable&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;bootstrap -l&lt;/code&gt; / &lt;code&gt;bash flex -l&lt;/code&gt; print the stage names before you commit to any of them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Self-documenting&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Usage lives in a comment block at the top of the file, read back out with &lt;code&gt;-h&lt;/code&gt;/&lt;code&gt;--help&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Fail soft&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A missing prerequisite warns and skips rather than aborting the rest of the run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;No secrets on the command line&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;stage&lt;/code&gt;'s bucket name isn't sensitive — IAM gates the objects, not the name — and &lt;code&gt;flex&lt;/code&gt; needs no credentials at all&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h4&gt;
  
  
  Where They Differ
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;stage&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;flex&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Target&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A Crostini &lt;em&gt;container&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;A bare-metal Debian &lt;em&gt;desktop&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Needs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;gcloud&lt;/code&gt;, an authorized Google account, a private bucket&lt;/td&gt;
&lt;td&gt;Nothing — no bucket, no login, no network dependency beyond the packages it fetches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Privilege&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Runs entirely unprivileged; the apt-managed &lt;code&gt;gcloud&lt;/code&gt; swap is the one non-fatal exception&lt;/td&gt;
&lt;td&gt;Only &lt;code&gt;pkgs&lt;/code&gt; touches &lt;code&gt;sudo&lt;/code&gt;/apt; the boot splash needs root but is never auto-run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Scope&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Bootstraps a dev environment: language runtimes, Docker, cloud and agent CLIs&lt;/td&gt;
&lt;td&gt;Cosmetic and GNOME-settings only: theme, shelf, keybindings, wallpaper&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;State model&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Additive — there is no &lt;code&gt;stage revert&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Explicit &lt;code&gt;bash flex revert&lt;/code&gt;, which &lt;em&gt;resets&lt;/em&gt; touched settings to GNOME defaults rather than restoring whatever was there before&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Hardware awareness&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;None — the same script regardless of what's underneath&lt;/td&gt;
&lt;td&gt;Written for, and against, one specific machine's GPU and monitor, generalized via Mutter's &lt;code&gt;DisplayConfig&lt;/code&gt; rather than hardcoded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Why it exists&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Reading credentials before credentials exist&lt;/td&gt;
&lt;td&gt;Recreating what real ChromeOS gave up when the GPU drove the OS choice&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h4&gt;
  
  
  Summary
&lt;/h4&gt;

&lt;p&gt;The goal here was to get a real NVIDIA driver stack under a ChromeOS-shaped workflow, without giving up the parts of ChromeOS that make a laptop pleasant to use day to day. The key to the solution was accepting that this is two separate problems, not one script with a hardware flag: &lt;code&gt;stage&lt;/code&gt; solves &lt;em&gt;getting credentials onto a machine that has none&lt;/em&gt;, and &lt;code&gt;flex&lt;/code&gt; solves &lt;em&gt;making a desktop look like ChromeOS after leaving ChromeOS entirely&lt;/em&gt; — for the same underlying reason, on two different machines, sharing nothing but a repo and a theme.&lt;/p&gt;

&lt;p&gt;The strategy for making Linux look like ChromeOS — once inside a sandboxed container, once on the bare metal underneath it — was validated with an incremental, stage-by-stage approach on both sides.&lt;/p&gt;

</description>
      <category>chromeos</category>
      <category>linux</category>
      <category>debian</category>
      <category>gnome</category>
    </item>
    <item>
      <title>Three Gemma 4 Deployments on One T4G for Under $3: What the Runtime Changes, and What It Doesn't</title>
      <dc:creator>xbill</dc:creator>
      <pubDate>Tue, 01 Sep 2026 18:25:50 +0000</pubDate>
      <link>https://dev.to/aws-builders/three-gemma-4-deployments-on-one-t4g-for-under-3-what-the-runtime-changes-and-what-it-doesnt-2cin</link>
      <guid>https://dev.to/aws-builders/three-gemma-4-deployments-on-one-t4g-for-under-3-what-the-runtime-changes-and-what-it-doesnt-2cin</guid>
      <description>&lt;p&gt;This article provides a step by step comparison of three Gemma 4 deployments on a single AWS hosted GPU enabled system. A suite of Python MCP tools is built to simplify management of each deployment, and one benchmark harness is shared across all three so that the runtime is the only variable.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/xbill9/gemma4-dev" rel="noopener noreferrer"&gt;https://github.com/xbill9/gemma4-dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The whole exercise cost under three dollars, and that is the part worth keeping. Nineteen instances and about four and a half instance-hours bought three serving sweeps, nine timed boots and a handful of A/B restarts. It also bought five wrong claims, each caught by measuring instead of reasoning. On hardware where a run is expensive, the cheapest of those five would have shipped with a caveat attached.&lt;/p&gt;

&lt;h4&gt;
  
  
  What is this project trying to Do?
&lt;/h4&gt;

&lt;p&gt;Three rigs in this monorepo serve &lt;code&gt;google/gemma-4-E2B-it&lt;/code&gt; on an AWS G5g instance. One runs vLLM, one runs a pure JAX port, one runs PyTorch with transformers. The hardware is identical and only the runtime slot moves, so this should be the cleanest A/B available.&lt;/p&gt;

&lt;p&gt;For months it was not, because each rig measured itself with its own harness and quoted its own number. Three harnesses computing three statistics is not a comparison.&lt;/p&gt;

&lt;h4&gt;
  
  
  Prerequisites
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;An AWS account with G-family quota in &lt;code&gt;us-east-1&lt;/code&gt;. Each &lt;code&gt;g5g.2xlarge&lt;/code&gt; is 8 vCPU, so 16 vCPU
of spot quota runs two at once.&lt;/li&gt;
&lt;li&gt;A subnet, a security group opening TCP 8000, and an instance profile carrying
&lt;code&gt;AmazonSSMManagedInstanceCore&lt;/code&gt; plus read on the Hugging Face token secret.&lt;/li&gt;
&lt;li&gt;A Hugging Face token in Secrets Manager. It is fetched at boot into a root-only
&lt;code&gt;EnvironmentFile&lt;/code&gt; and never appears in user data.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;boto3&lt;/code&gt; and the standard credential chain. No AWS CLI shell-outs, no inbound SSH rule, and
no private key anywhere in the flow.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  AWS EC2 G5g
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Instance&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;g5g.2xlarge&lt;/code&gt; — 8 vCPU, 16 GiB host&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Host CPU&lt;/td&gt;
&lt;td&gt;AWS Graviton2, aarch64&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU&lt;/td&gt;
&lt;td&gt;1x NVIDIA T4G, Turing, SM 7.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU memory&lt;/td&gt;
&lt;td&gt;15,360 MiB per &lt;code&gt;nvidia-smi&lt;/code&gt;; AWS lists 16,384 nominal&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;G5g is the only family AWS ships that puts an NVIDIA GPU behind a Graviton host, which makes it the only place to get aarch64 and compute capability 7.5 together.&lt;/p&gt;

&lt;h4&gt;
  
  
  Gemma 4 E2B
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;google/gemma-4-E2B-it&lt;/code&gt; is the reference instruction-tuned release. It is 2B effective from about 5B total, and the split matters here: most of what is resident is a per-layer-embedding table that decode reads as a gather and never streams through a matmul.&lt;/p&gt;

&lt;p&gt;The dense checkpoint fits. 9.5 GiB of float16 weights go into 15,360 MiB of device memory with room for the KV cache, which at roughly 18 KiB per token is tens of megabytes at this context and never the binding constraint.&lt;/p&gt;

&lt;h4&gt;
  
  
  The Three Runtimes
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;runtime&lt;/th&gt;
&lt;th&gt;engine&lt;/th&gt;
&lt;th&gt;how it serves&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;vLLM&lt;/td&gt;
&lt;td&gt;v0.27.2rc0, built from source for &lt;code&gt;sm_75&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;continuous batching, paged KV, prefix caching&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JAX&lt;/td&gt;
&lt;td&gt;this project's own port&lt;/td&gt;
&lt;td&gt;hand-written KV ring with a bucket ladder&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PyTorch&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;AutoModelForCausalLM&lt;/code&gt; + transformers&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;past_key_values&lt;/code&gt;, one request at a time&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Turing has no bfloat16 datapath, so all three run float16. It has no fp8 either, which rules out the KV-cache tricks that work on newer parts.&lt;/p&gt;

&lt;h4&gt;
  
  
  Check the Quotas
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;check_g5g_quotas
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;| Quota | vCPUs |
| Running On-Demand G and VT instances (vCPU) | 16 |
| All G and VT Spot Instance Requests (vCPU) | 16 |

&lt;span class="sb"&gt;`g5g.2xlarge`&lt;/span&gt; needs 8 vCPUs.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the constraint behind every launch below: two rigs in parallel, and no more.&lt;/p&gt;

&lt;h4&gt;
  
  
  The Sweep Could Not See vLLM
&lt;/h4&gt;

&lt;p&gt;The sweep script read its throughput figure straight out of the response body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;decode_tps&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;decode_tokens_per_second&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;usage.decode_tokens_per_second&lt;/code&gt; is a field our own servers invent. vLLM does not emit it, and neither does anything else, so the harness could not be pointed at the vLLM rig at all. The three-way comparison had never actually been run.&lt;/p&gt;

&lt;p&gt;Re-running a rig does not fix that. Only a common statistic does.&lt;/p&gt;

&lt;h4&gt;
  
  
  One Statistic, Three Servers
&lt;/h4&gt;

&lt;p&gt;Every OpenAI-compatible server streams, so the portable measurement is the gap between tokens on the wire.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 sweep.py &lt;span class="nt"&gt;--help&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A2&lt;/span&gt; decode-source
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  --decode-source {auto,usage,stream,both}
                        where the decode figure comes from; see the module
                        docstring
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;stream&lt;/code&gt; path uses &lt;code&gt;vllm bench serve&lt;/code&gt;'s exact TPOT definition, &lt;code&gt;(latency - ttft) / (output_len - 1)&lt;/code&gt;, so a number from this harness is directly comparable to that tool's published figures. &lt;code&gt;auto&lt;/code&gt; probes the endpoint once and picks &lt;code&gt;both&lt;/code&gt; where the server emits its own gauge, &lt;code&gt;stream&lt;/code&gt; where it does not.&lt;/p&gt;

&lt;h4&gt;
  
  
  Is the Calibration Transferable?
&lt;/h4&gt;

&lt;p&gt;No, and that is worth a measurement rather than an assumption. Running &lt;code&gt;both&lt;/code&gt; measures each rig's offset between the two statistics.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;rig&lt;/th&gt;
&lt;th&gt;server gauge&lt;/th&gt;
&lt;th&gt;client stream&lt;/th&gt;
&lt;th&gt;stream/gauge&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;JAX&lt;/td&gt;
&lt;td&gt;12.962&lt;/td&gt;
&lt;td&gt;12.687&lt;/td&gt;
&lt;td&gt;0.9799&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PyTorch&lt;/td&gt;
&lt;td&gt;10.814&lt;/td&gt;
&lt;td&gt;10.243&lt;/td&gt;
&lt;td&gt;0.9543&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two percent against 4.6 percent, on the same day and the same instance shape. Borrowing one rig's ratio to convert the other's number would inject a 2.6 percent error into a comparison whose smallest interesting gap is 24 percent. The cross-rig table below is therefore built from &lt;code&gt;stream&lt;/code&gt; throughout.&lt;/p&gt;

&lt;h4&gt;
  
  
  A Gauge Rounded to One Decimal
&lt;/h4&gt;

&lt;p&gt;The JAX server emitted its decode gauge with one decimal place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;tpu_jax_decode_tokens_per_second{{model=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;MODEL_ID&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;}} &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;METRICS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;last_tokens_per_second&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At about 13 tok/s, one decimal is 0.78 percent resolution. Every sweep that rig had produced showed all three repeats of a cell as byte-identical: 12.8, 12.8, 12.8. That is not reproducibility, it is the measurement floor. The rig had been used to argue about two percent effects it could not resolve.&lt;/p&gt;

&lt;p&gt;Two characters fixed it. The first run afterwards reads 12.962, where before it would have said 13.0.&lt;/p&gt;

&lt;h4&gt;
  
  
  Launch the Instance
&lt;/h4&gt;

&lt;p&gt;Capacity for the whole G5g family was exhausted across all four availability zones several times, so the launcher cycles them with a sixty second backoff.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;[12:51:45] round 5 us-east-1c: ❌ AWS InsufficientInstanceCapacity
[12:52:47] us-east-1a: ✅ Launching `i-02e79988a6cbeecbf` (g5g.2xlarge, spot, 1x T4G) in `us-east-1`.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The walkthrough from here follows the PyTorch rig on &lt;code&gt;i-02e79988a6cbeecbf&lt;/code&gt;; the other two run the same steps against their own instances. All three landed in &lt;code&gt;us-east-1a&lt;/code&gt; within hours of each other. Note that AWS names the other zones as available in every one of those errors — that text describes on-demand capacity and says nothing about spot.&lt;/p&gt;

&lt;h4&gt;
  
  
  Watch the Install
&lt;/h4&gt;

&lt;p&gt;Cloud-init installs the runtime and then backgrounds itself, so the progress tool reports cloud-init's own state as well as the install log. A dead bootstrap and a slow one must not render identically.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;get_install_progress i-02e79988a6cbeecbf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;INSTALL COMPLETE&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt; &lt;span class="s"&gt;cloud-init ---&lt;/span&gt;
&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;done&lt;/span&gt;
&lt;span class="na"&gt;errors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a wheel install, not a build. Across the three timed boots the install finishes a median 113.55 s after launch, against the hours the vLLM rig needs for a from-source build.&lt;/p&gt;

&lt;h4&gt;
  
  
  Verify the GPU
&lt;/h4&gt;

&lt;p&gt;A config flag being accepted proves nothing, so the probe runs a real matmul on the device.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;verify_gpu_arch i-02e79988a6cbeecbf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;NVIDIA T4G, 7.5, 15360 MiB&lt;/span&gt;
&lt;span class="na"&gt;torch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2.12.0+cu132&lt;/span&gt;
&lt;span class="na"&gt;arch_list&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sm_75'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sm_80'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sm_90'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sm_100'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sm_110'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sm_120'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;capability&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;(7, 5)&lt;/span&gt;
&lt;span class="na"&gt;compute_dtype&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;float16&lt;/span&gt;
&lt;span class="na"&gt;fp16 matmul ok&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;True&lt;/span&gt;

&lt;span class="s"&gt;✅ torch reached the GPU and a real fp16 matmul executed.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The DLAMI's torch carries &lt;code&gt;sm_75&lt;/code&gt;. Upstream PyPI aarch64 wheels do not, so a &lt;code&gt;pip install torch&lt;/code&gt; on this box would serve on CPU without saying so.&lt;/p&gt;

&lt;h4&gt;
  
  
  Deploy the Server
&lt;/h4&gt;

&lt;p&gt;The payload is the rig's own source, shipped over SSM as a gzipped tarball because user data caps at 16 KiB.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;deploy_torch_server i-02e79988a6cbeecbf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;✅ Deployed 3 files (16 KiB base64) to &lt;span class="sb"&gt;`i-02e79988a6cbeecbf`&lt;/span&gt;.

Payload root: &lt;span class="sb"&gt;`/home/xbill/gemma4-dev/gpu-pytorch-g5g-2b`&lt;/span&gt;
Build id: &lt;span class="sb"&gt;`060a572aeb55`&lt;/span&gt; — verify_model_health checks the running server reports this.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Verify the Installation
&lt;/h4&gt;

&lt;p&gt;A non-empty reply is not evidence of health. One sibling was once measured answering &lt;code&gt;': ok: ok: ok…'&lt;/code&gt;, so the check reads the server's own degenerate-response counter either side of its probe, and compares the served build id against the local payload.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;verify_model_health i-02e79988a6cbeecbf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;✅ health=200 tokens=5 reply='ok'
&lt;span class="p"&gt;
-&lt;/span&gt; Degenerate (server's own verdict on the full text): &lt;span class="gs"&gt;**no**&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Build id served: &lt;span class="sb"&gt;`060a572aeb55`&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Build id matches the local payload (&lt;span class="sb"&gt;`060a572aeb55`&lt;/span&gt;).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Run the Sweep
&lt;/h4&gt;

&lt;p&gt;The same command runs against all three rigs. Only the endpoint changes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 sweep.py &lt;span class="nt"&gt;--base&lt;/span&gt; http://&amp;lt;ip&amp;gt;:8000/v1 &lt;span class="nt"&gt;--out&lt;/span&gt; benchmarks/runs/&amp;lt;run&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--contexts&lt;/span&gt; 64,512,1024,2048,3072,3800 &lt;span class="nt"&gt;--outputs&lt;/span&gt; 32,128 &lt;span class="nt"&gt;--repeats&lt;/span&gt; 3 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--decode-source&lt;/span&gt; both
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;decode-source: both -&amp;gt; both
ctx~512 out=32: in=633 out=32 decode=10.96 tok/s  e2e=9.83 tok/s (warmup 11.08)  stream/usage=0.9616
ctx~2048 out=128: in=2501 out=89 decode=10.66 tok/s  e2e=9.16 tok/s (warmup 10.61)  stream/usage=0.9540
ctx~3800 out=32: FAILED HTTP Error 400 {"detail":"prompt is 4630 tokens and the context
  bound is 4096, leaving no room to decode. Start the server with a larger --seq."}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cells that cannot exist on the hardware are recorded as &lt;code&gt;infeasible&lt;/code&gt; rather than dropped. An absent cell is indistinguishable from an untried one, which is how a sweep overstates its own coverage.&lt;/p&gt;

&lt;h4&gt;
  
  
  Decode at Concurrency One
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;runtime&lt;/th&gt;
&lt;th&gt;decode tok/s&lt;/th&gt;
&lt;th&gt;% of ceiling&lt;/th&gt;
&lt;th&gt;vs PyTorch&lt;/th&gt;
&lt;th&gt;cells&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;🥇&lt;/td&gt;
&lt;td&gt;vLLM v0.27.2rc0&lt;/td&gt;
&lt;td&gt;32.53&lt;/td&gt;
&lt;td&gt;53.0%&lt;/td&gt;
&lt;td&gt;3.18x&lt;/td&gt;
&lt;td&gt;12/12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🥈&lt;/td&gt;
&lt;td&gt;JAX&lt;/td&gt;
&lt;td&gt;12.69&lt;/td&gt;
&lt;td&gt;20.7%&lt;/td&gt;
&lt;td&gt;1.24x&lt;/td&gt;
&lt;td&gt;10/12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🥉&lt;/td&gt;
&lt;td&gt;PyTorch + transformers&lt;/td&gt;
&lt;td&gt;10.24&lt;/td&gt;
&lt;td&gt;16.7%&lt;/td&gt;
&lt;td&gt;1.00x&lt;/td&gt;
&lt;td&gt;10/12&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h4&gt;
  
  
  How Close Is That to the Hardware?
&lt;/h4&gt;

&lt;p&gt;The ceiling is arithmetic, not a measurement. E2B streams 4.514 GB of weights per decode step against a measured 277 GB/s, giving 16.30 ms per step and &lt;strong&gt;61.4 tok/s&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The PLE table is excluded from that figure because it is a gather and never a matmul. Quantising it from 9.257 GB to 5.752 GB moved decode by 0.00 tok/s across three cells, which is what confirms it never streams.&lt;/p&gt;

&lt;p&gt;All three runtimes sit far below the ceiling, so &lt;strong&gt;none of them is bandwidth-bound at batch one&lt;/strong&gt;. The PyTorch profile shows why: about 5,650 kernel launches per step at one to three microseconds each, on a chip whose launch overhead is five to ten.&lt;/p&gt;

&lt;h4&gt;
  
  
  The Number I Did Not Expect
&lt;/h4&gt;

&lt;p&gt;Time to first token was the result of the run, right up until it was checked.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;input tok&lt;/th&gt;
&lt;th&gt;vLLM&lt;/th&gt;
&lt;th&gt;JAX&lt;/th&gt;
&lt;th&gt;PyTorch&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;92&lt;/td&gt;
&lt;td&gt;103 ms&lt;/td&gt;
&lt;td&gt;225 ms&lt;/td&gt;
&lt;td&gt;164 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,259&lt;/td&gt;
&lt;td&gt;118 ms&lt;/td&gt;
&lt;td&gt;1,615 ms&lt;/td&gt;
&lt;td&gt;657 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3,746&lt;/td&gt;
&lt;td&gt;178 ms&lt;/td&gt;
&lt;td&gt;5,352 ms&lt;/td&gt;
&lt;td&gt;2,339 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A 30x advantage, far larger than the 3.2x on decode. It is also impossible. Prefill at 3,746 tokens is roughly 14 TFLOP against a T4G's realistic 20 to 30 TFLOP/s, which is 460 ms at best. vLLM's row says 178 ms.&lt;/p&gt;

&lt;p&gt;It did not. vLLM ships &lt;code&gt;enable_prefix_caching=True&lt;/code&gt;, and its own metrics say so:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"^vllm:prefix_cache_(queries|hits)_total"&lt;/span&gt; metrics.prom
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight prometheus"&gt;&lt;code&gt;&lt;span class="n"&gt;vllm:prefix_cache_queries_total&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;engine&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"0"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="mf"&gt;102898.0&lt;/span&gt;
&lt;span class="n"&gt;vllm:prefix_cache_hits_total&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;engine&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"0"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;     &lt;span class="mf"&gt;97440.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;A 94.7 percent hit rate.&lt;/strong&gt; vLLM genuinely prefilled 5.3 percent of the tokens it was sent, because the harness reused one prompt for a cell's warm-up and all three repeats. Neither sibling has a prefix cache, so both paid full prefill every time.&lt;/p&gt;

&lt;p&gt;The fix places a nonce first in the prompt, since a shared prefix is exactly what the cache keys on and a trailing nonce would not have defeated it. That property is now a unit test.&lt;/p&gt;

&lt;h4&gt;
  
  
  What the Prefill Data Does Support
&lt;/h4&gt;

&lt;p&gt;Strip the contaminated column and a real result remains. Neither of the other two runtimes caches prefixes, and both saw identical prompts.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;TTFT slope&lt;/th&gt;
&lt;th&gt;at 3,746 tokens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;JAX&lt;/td&gt;
&lt;td&gt;1.403 ms/token&lt;/td&gt;
&lt;td&gt;5,352 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PyTorch&lt;/td&gt;
&lt;td&gt;0.595 ms/token&lt;/td&gt;
&lt;td&gt;2,339 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;JAX prefills 2.4x slower than PyTorch&lt;/strong&gt;, consistently across all five shared context lengths. On an interactive workload with real context that dominates user-visible latency, and it runs in the opposite direction to the 1.24x decode advantage the same rig enjoys.&lt;/p&gt;

&lt;h4&gt;
  
  
  Boot Time Reverses the Ranking
&lt;/h4&gt;

&lt;p&gt;Nine cold boots, three per runtime, plus nine warm reloads. The start line is the moment &lt;code&gt;run_instances&lt;/code&gt; returns an id, because capacity wait measures AWS rather than the rig.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;runtime&lt;/th&gt;
&lt;th&gt;cold boot&lt;/th&gt;
&lt;th&gt;spread&lt;/th&gt;
&lt;th&gt;warm reload&lt;/th&gt;
&lt;th&gt;cold/warm&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;🥇&lt;/td&gt;
&lt;td&gt;PyTorch&lt;/td&gt;
&lt;td&gt;195.2 s&lt;/td&gt;
&lt;td&gt;11.8%&lt;/td&gt;
&lt;td&gt;24.5 s&lt;/td&gt;
&lt;td&gt;8.0x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🥈&lt;/td&gt;
&lt;td&gt;JAX&lt;/td&gt;
&lt;td&gt;242.2 s&lt;/td&gt;
&lt;td&gt;11.5%&lt;/td&gt;
&lt;td&gt;74.1 s&lt;/td&gt;
&lt;td&gt;3.3x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🥉&lt;/td&gt;
&lt;td&gt;vLLM&lt;/td&gt;
&lt;td&gt;1417.8 s&lt;/td&gt;
&lt;td&gt;12.6%&lt;/td&gt;
&lt;td&gt;264.3 s&lt;/td&gt;
&lt;td&gt;5.4x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;vLLM takes 23m 38s to serve, from a prebuilt AMI that downloads nothing.&lt;/strong&gt; PyTorch installs its runtime from wheels and pulls the 9.54 GiB checkpoint over the network, and is still 7.3x faster.&lt;/p&gt;

&lt;p&gt;Boot variance is 11.5, 11.8 and 12.6 percent — too consistent across three different runtimes to be a property of any of them. This family's decode noise floor is 1.7 percent, measured by running an identical build on two hosts, so boot is about seven times noisier and a single boot measurement is close to worthless.&lt;/p&gt;

&lt;h4&gt;
  
  
  Is Health 200 the Same as Ready?
&lt;/h4&gt;

&lt;p&gt;Not on every runtime, which is why the harness records two stop lines.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;runtime&lt;/th&gt;
&lt;th&gt;first completion, cold&lt;/th&gt;
&lt;th&gt;warm&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;vLLM&lt;/td&gt;
&lt;td&gt;0.5 s&lt;/td&gt;
&lt;td&gt;0.2 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PyTorch&lt;/td&gt;
&lt;td&gt;1.0 s&lt;/td&gt;
&lt;td&gt;0.7 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JAX&lt;/td&gt;
&lt;td&gt;22.9 s&lt;/td&gt;
&lt;td&gt;9.2 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;JAX returns health 200 and then compiles XLA per shape bucket on the first real request. Quoting health alone understates its time to serving by 22 seconds, and the cost does not vanish when warm. vLLM is the mirror image: slowest to boot, fastest first token, because graph capture is paid before the port binds.&lt;/p&gt;

&lt;h4&gt;
  
  
  What Does a Code Change Cost?
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;runtime&lt;/th&gt;
&lt;th&gt;to change serving code&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PyTorch&lt;/td&gt;
&lt;td&gt;ship 3 files over SSM, restart — 25 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JAX&lt;/td&gt;
&lt;td&gt;same mechanism, plus 9.2 s of compile — 83 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;vLLM&lt;/td&gt;
&lt;td&gt;no deploy path exists: rebuild from source, ~67 min, and reapply an out-of-tree Turing patch&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;vLLM's 264 s warm figure is a &lt;code&gt;systemctl restart&lt;/code&gt;, not a code change, so it flatters the comparison. &lt;strong&gt;vLLM wins decode 3.2x and loses the iteration loop by 3 to 100x.&lt;/strong&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  Five Theories About 546 Seconds
&lt;/h4&gt;

&lt;p&gt;vLLM's cold boot is dominated by weight loading: 468 to 561 seconds across four measurements. Explaining it took five attempts, four of which were wrong.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;em&gt;&lt;code&gt;g5g.2xlarge&lt;/code&gt; needs no swapfile and buys that time back.&lt;/em&gt; The rig's own documentation.
Falsified by the three-boot campaign at 23m 38s.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;A bigger host will not fix it.&lt;/em&gt; Retracted the same day — no large host had been measured.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;A larger host would very plausibly fix it.&lt;/em&gt; Falsified by one &lt;code&gt;g5g.4xlarge&lt;/code&gt; boot: available
RAM 11.19 to 26.49 GiB, weight loading 546 to 468 s, and total boot 4.7 percent lower,
inside the noise band.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;It is the loader; vLLM's log says auto-prefetch is disabled on EXT4.&lt;/em&gt; Falsified by a
within-box A/B — 76.13 s as shipped against 75.12 s with
&lt;code&gt;--safetensors-load-strategy=prefetch&lt;/code&gt;, which is 1.3 percent and therefore nothing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What that last run did find is the useful part.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;weight load&lt;/th&gt;
&lt;th&gt;n&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;cold boot, fresh instance&lt;/td&gt;
&lt;td&gt;468-561 s&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;warm restart, same box&lt;/td&gt;
&lt;td&gt;32-76 s&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same volume, same filesystem, same engine, differing only in whether the blocks had been read once. 9.54 GiB in 468 s is about 21 MiB/s, which is absurd for gp3 steady state and ordinary for first-touch reads against a snapshot-backed volume.&lt;/p&gt;

&lt;p&gt;Theory five is EBS lazily hydrating the volume from the AMI snapshot, and it is written down as untested. Given the strike rate it does not get promoted by reasoning.&lt;/p&gt;

&lt;h4&gt;
  
  
  Why the First Campaign Was Thrown Away
&lt;/h4&gt;

&lt;p&gt;The first boot campaign was discarded and re-run. Two independent instances had reported 214.4 s and 125.1 s, identical to the tenth. That is not consistency. It is a five second poll quantising two similar boots onto the same tick.&lt;/p&gt;

&lt;p&gt;The data was not wrong; the campaign log shows 215 s and 216 s of wall clock. It was unusably coarse. Health polling went to half a second, and the next pair of boots came in at 216.90 s and 193.86 s — an 11.9 percent spread the old harness could not see.&lt;/p&gt;

&lt;h4&gt;
  
  
  And Price/Performance?
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;$/hr&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;g5g.2xlarge&lt;/code&gt; on-demand&lt;/td&gt;
&lt;td&gt;$0.556&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;spot, measured across four AZs&lt;/td&gt;
&lt;td&gt;$0.3813 - $0.4416&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The whole exercise — 19 instances, 4.06 hours of &lt;code&gt;g5g.2xlarge&lt;/code&gt; plus 0.38 of &lt;code&gt;g5g.4xlarge&lt;/code&gt;, a serving sweep, three cross-rig runs, nine boots and three A/B restarts — came to under $3. That is arithmetic rather than a bill: AWS drops terminated instances after an hour, so the derivation in &lt;code&gt;cost_derivation.md&lt;/code&gt; bounds it at $1.84 all-spot and $2.68 all-on-demand.&lt;/p&gt;

&lt;p&gt;That 26 to 46 percent premium is the entire spot versus on-demand decision on this hardware, which is to say there is not one. Try spot, fall back, keep working; the automatic fallback cost about $0.24 across a nine-boot campaign.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cheap hardware is what made the method possible, not merely affordable.&lt;/strong&gt; Discarding a completed campaign over a poll-interval bug cost twenty minutes and pennies. Where a run is expensive, the same discovery argues for shipping the numbers with a caveat instead.&lt;/p&gt;

&lt;h4&gt;
  
  
  AWS Services Used
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;service&lt;/th&gt;
&lt;th&gt;what it does here&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;EC2&lt;/td&gt;
&lt;td&gt;the &lt;code&gt;g5g.2xlarge&lt;/code&gt; instances, spot and on-demand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Systems Manager&lt;/td&gt;
&lt;td&gt;every remote command; there is no inbound SSH rule and no private key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Secrets Manager&lt;/td&gt;
&lt;td&gt;the Hugging Face token, fetched at boot into a root-only &lt;code&gt;EnvironmentFile&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IAM&lt;/td&gt;
&lt;td&gt;one instance profile, &lt;code&gt;AmazonSSMManagedInstanceCore&lt;/code&gt; plus read on that one secret&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EBS&lt;/td&gt;
&lt;td&gt;gp3 root volumes, and the AMI snapshot behind the vLLM boot mystery&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h4&gt;
  
  
  Clean Up
&lt;/h4&gt;

&lt;p&gt;Every instance is terminated as soon as its artifacts are captured. There is no built image to lose, only a pip install and a model cache.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;terminate_g5g_instance i-02e79988a6cbeecbf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;🗑️ Terminating &lt;span class="sb"&gt;`i-02e79988a6cbeecbf`&lt;/span&gt;. Relaunch costs a pip install, not a build.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ec2 describe-instances &lt;span class="nt"&gt;--filters&lt;/span&gt; &lt;span class="s2"&gt;"Name=instance-state-name,Values=running"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Reservations[].Instances[?starts_with(InstanceType,`g5g`)].InstanceId'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; text | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"🟢 none running"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;🟢 none running
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  What This Does Not Cover
&lt;/h4&gt;

&lt;p&gt;Everything above is concurrency one, which makes it a latency comparison rather than a serving one. Continuous batching is vLLM's whole value proposition and it is untested here.&lt;/p&gt;

&lt;p&gt;Only vLLM can serve concurrently at all today. The PyTorch server holds an &lt;code&gt;asyncio.Lock&lt;/code&gt; with the comment &lt;em&gt;"one GPU, one process -&amp;gt; serialize requests"&lt;/em&gt;, and the JAX rig has no batching machinery whatever. The engine-level batch sweep suggests what is on the table — batch eight reaches 84.16 tok/s for an extra 0.258 GB, with per-step time growing two percent across an eight-fold batch — but that number never leaves the engine.&lt;/p&gt;

&lt;p&gt;Three further gaps: the JAX leg ran its shipped quantised configuration against two dense runtimes; no output-quality axis was measured at all, on a comparison where one runtime uses a deliberately lossy LM head; and every TTFT figure predates the prompt-uniqueness fix, so only the JAX versus PyTorch half of that table is sound.&lt;/p&gt;

&lt;h4&gt;
  
  
  Summary
&lt;/h4&gt;

&lt;p&gt;The goal of this article was to compare three inference runtimes on identical silicon without the harness being a variable. The key to the solution was a single client-side statistic that every OpenAI-compatible server can produce. The measured results were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Decode at concurrency one: vLLM 32.53, JAX 12.69, PyTorch 10.24 tok/s — 53, 21 and 17
percent of a 61.4 tok/s bandwidth ceiling, so none is bandwidth-bound.&lt;/li&gt;
&lt;li&gt;The ranking reverses on lifecycle. Cold boot 195.2 s for PyTorch against 1417.8 s for vLLM;
a code change costs 25 s against a from-source rebuild.&lt;/li&gt;
&lt;li&gt;JAX prefills 2.4x slower than PyTorch, 1.403 against 0.595 ms/token.&lt;/li&gt;
&lt;li&gt;A 94.7 percent prefix-cache hit rate turned a 30x TTFT result into a harness artifact.&lt;/li&gt;
&lt;li&gt;Calibration offsets are per-rig, 0.9799 and 0.9543, and are not transferable.&lt;/li&gt;
&lt;li&gt;Boot variance is about 12 percent on this platform regardless of runtime.&lt;/li&gt;
&lt;li&gt;The whole exercise cost under $3 in total. That is what made discarding a finished campaign
over a poll-interval bug a twenty minute decision rather than an argument.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scope: the decode and boot numbers are one &lt;code&gt;g5g.2xlarge&lt;/code&gt; per runtime in &lt;code&gt;us-east-1a&lt;/code&gt; on 2026-08-31, three repeats per cell and three repeats per boot, mixed spot and on-demand with the market recorded per run. Three things differed between the legs and are named where they matter: vLLM ran &lt;code&gt;max_model_len&lt;/code&gt; 16384 against 4096 for the other two; the JAX leg ran its shipped quantised configuration against two dense runtimes; and vLLM booted from a prebuilt AMI carrying its model cache while the other two installed from wheels and downloaded the checkpoint, which is the point of the boot comparison rather than a flaw in it. Two runs sit outside that envelope and say so in the text: the RAM test was a single &lt;code&gt;g5g.4xlarge&lt;/code&gt;, and the prefetch A/B ran 2026-09-01 on on-demand after a spot reclamation killed the first attempt. Decode is unaffected by the prefix-cache issue, which changes prefill only.&lt;/p&gt;

&lt;p&gt;The strategy for using MCP for multi-runtime comparison was validated with a incremental step by step approach.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>machinelearning</category>
      <category>benchmarking</category>
      <category>python</category>
    </item>
    <item>
      <title>Three Gemma 4 Deployments on One T4G for Under $3: What the Runtime Changes, and What It Doesn't</title>
      <dc:creator>xbill</dc:creator>
      <pubDate>Tue, 01 Sep 2026 18:25:48 +0000</pubDate>
      <link>https://dev.to/gde/three-gemma-4-deployments-on-one-t4g-for-under-3-what-the-runtime-changes-and-what-it-doesnt-jo3</link>
      <guid>https://dev.to/gde/three-gemma-4-deployments-on-one-t4g-for-under-3-what-the-runtime-changes-and-what-it-doesnt-jo3</guid>
      <description>&lt;p&gt;This article provides a step by step comparison of three Gemma 4 deployments on a single AWS hosted GPU enabled system. A suite of Python MCP tools is built to simplify management of each deployment, and one benchmark harness is shared across all three so that the runtime is the only variable.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/xbill9/gemma4-dev" rel="noopener noreferrer"&gt;https://github.com/xbill9/gemma4-dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The whole exercise cost under three dollars, and that is the part worth keeping. Nineteen instances and about four and a half instance-hours bought three serving sweeps, nine timed boots and a handful of A/B restarts. It also bought five wrong claims, each caught by measuring instead of reasoning. On hardware where a run is expensive, the cheapest of those five would have shipped with a caveat attached.&lt;/p&gt;

&lt;h4&gt;
  
  
  What is this project trying to Do?
&lt;/h4&gt;

&lt;p&gt;Three rigs in this monorepo serve &lt;code&gt;google/gemma-4-E2B-it&lt;/code&gt; on an AWS G5g instance. One runs vLLM, one runs a pure JAX port, one runs PyTorch with transformers. The hardware is identical and only the runtime slot moves, so this should be the cleanest A/B available.&lt;/p&gt;

&lt;p&gt;For months it was not, because each rig measured itself with its own harness and quoted its own number. Three harnesses computing three statistics is not a comparison.&lt;/p&gt;

&lt;h4&gt;
  
  
  Prerequisites
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;An AWS account with G-family quota in &lt;code&gt;us-east-1&lt;/code&gt;. Each &lt;code&gt;g5g.2xlarge&lt;/code&gt; is 8 vCPU, so 16 vCPU
of spot quota runs two at once.&lt;/li&gt;
&lt;li&gt;A subnet, a security group opening TCP 8000, and an instance profile carrying
&lt;code&gt;AmazonSSMManagedInstanceCore&lt;/code&gt; plus read on the Hugging Face token secret.&lt;/li&gt;
&lt;li&gt;A Hugging Face token in Secrets Manager. It is fetched at boot into a root-only
&lt;code&gt;EnvironmentFile&lt;/code&gt; and never appears in user data.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;boto3&lt;/code&gt; and the standard credential chain. No AWS CLI shell-outs, no inbound SSH rule, and
no private key anywhere in the flow.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  AWS EC2 G5g
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Instance&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;g5g.2xlarge&lt;/code&gt; — 8 vCPU, 16 GiB host&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Host CPU&lt;/td&gt;
&lt;td&gt;AWS Graviton2, aarch64&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU&lt;/td&gt;
&lt;td&gt;1x NVIDIA T4G, Turing, SM 7.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU memory&lt;/td&gt;
&lt;td&gt;15,360 MiB per &lt;code&gt;nvidia-smi&lt;/code&gt;; AWS lists 16,384 nominal&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;G5g is the only family AWS ships that puts an NVIDIA GPU behind a Graviton host, which makes it the only place to get aarch64 and compute capability 7.5 together.&lt;/p&gt;

&lt;h4&gt;
  
  
  Gemma 4 E2B
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;google/gemma-4-E2B-it&lt;/code&gt; is the reference instruction-tuned release. It is 2B effective from about 5B total, and the split matters here: most of what is resident is a per-layer-embedding table that decode reads as a gather and never streams through a matmul.&lt;/p&gt;

&lt;p&gt;The dense checkpoint fits. 9.5 GiB of float16 weights go into 15,360 MiB of device memory with room for the KV cache, which at roughly 18 KiB per token is tens of megabytes at this context and never the binding constraint.&lt;/p&gt;

&lt;h4&gt;
  
  
  The Three Runtimes
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;runtime&lt;/th&gt;
&lt;th&gt;engine&lt;/th&gt;
&lt;th&gt;how it serves&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;vLLM&lt;/td&gt;
&lt;td&gt;v0.27.2rc0, built from source for &lt;code&gt;sm_75&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;continuous batching, paged KV, prefix caching&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JAX&lt;/td&gt;
&lt;td&gt;this project's own port&lt;/td&gt;
&lt;td&gt;hand-written KV ring with a bucket ladder&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PyTorch&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;AutoModelForCausalLM&lt;/code&gt; + transformers&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;past_key_values&lt;/code&gt;, one request at a time&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Turing has no bfloat16 datapath, so all three run float16. It has no fp8 either, which rules out the KV-cache tricks that work on newer parts.&lt;/p&gt;

&lt;h4&gt;
  
  
  Check the Quotas
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;check_g5g_quotas
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;| Quota | vCPUs |
| Running On-Demand G and VT instances (vCPU) | 16 |
| All G and VT Spot Instance Requests (vCPU) | 16 |

&lt;span class="sb"&gt;`g5g.2xlarge`&lt;/span&gt; needs 8 vCPUs.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the constraint behind every launch below: two rigs in parallel, and no more.&lt;/p&gt;

&lt;h4&gt;
  
  
  The Sweep Could Not See vLLM
&lt;/h4&gt;

&lt;p&gt;The sweep script read its throughput figure straight out of the response body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;decode_tps&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;decode_tokens_per_second&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;usage.decode_tokens_per_second&lt;/code&gt; is a field our own servers invent. vLLM does not emit it, and neither does anything else, so the harness could not be pointed at the vLLM rig at all. The three-way comparison had never actually been run.&lt;/p&gt;

&lt;p&gt;Re-running a rig does not fix that. Only a common statistic does.&lt;/p&gt;

&lt;h4&gt;
  
  
  One Statistic, Three Servers
&lt;/h4&gt;

&lt;p&gt;Every OpenAI-compatible server streams, so the portable measurement is the gap between tokens on the wire.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 sweep.py &lt;span class="nt"&gt;--help&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A2&lt;/span&gt; decode-source
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  &lt;span class="nt"&gt;--decode-source&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;auto,usage,stream,both&lt;span class="o"&gt;}&lt;/span&gt;
                        where the decode figure comes from&lt;span class="p"&gt;;&lt;/span&gt; see the module
                        docstring
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;stream&lt;/code&gt; path uses &lt;code&gt;vllm bench serve&lt;/code&gt;'s exact TPOT definition, &lt;code&gt;(latency - ttft) / (output_len - 1)&lt;/code&gt;, so a number from this harness is directly comparable to that tool's published figures. &lt;code&gt;auto&lt;/code&gt; probes the endpoint once and picks &lt;code&gt;both&lt;/code&gt; where the server emits its own gauge, &lt;code&gt;stream&lt;/code&gt; where it does not.&lt;/p&gt;

&lt;h4&gt;
  
  
  Is the Calibration Transferable?
&lt;/h4&gt;

&lt;p&gt;No, and that is worth a measurement rather than an assumption. Running &lt;code&gt;both&lt;/code&gt; measures each rig's offset between the two statistics.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;rig&lt;/th&gt;
&lt;th&gt;server gauge&lt;/th&gt;
&lt;th&gt;client stream&lt;/th&gt;
&lt;th&gt;stream/gauge&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;JAX&lt;/td&gt;
&lt;td&gt;12.962&lt;/td&gt;
&lt;td&gt;12.687&lt;/td&gt;
&lt;td&gt;0.9799&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PyTorch&lt;/td&gt;
&lt;td&gt;10.814&lt;/td&gt;
&lt;td&gt;10.243&lt;/td&gt;
&lt;td&gt;0.9543&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two percent against 4.6 percent, on the same day and the same instance shape. Borrowing one rig's ratio to convert the other's number would inject a 2.6 percent error into a comparison whose smallest interesting gap is 24 percent. The cross-rig table below is therefore built from &lt;code&gt;stream&lt;/code&gt; throughout.&lt;/p&gt;

&lt;h4&gt;
  
  
  A Gauge Rounded to One Decimal
&lt;/h4&gt;

&lt;p&gt;The JAX server emitted its decode gauge with one decimal place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;tpu_jax_decode_tokens_per_second{{model=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;MODEL_ID&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;}} &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;METRICS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;last_tokens_per_second&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At about 13 tok/s, one decimal is 0.78 percent resolution. Every sweep that rig had produced showed all three repeats of a cell as byte-identical: 12.8, 12.8, 12.8. That is not reproducibility, it is the measurement floor. The rig had been used to argue about two percent effects it could not resolve.&lt;/p&gt;

&lt;p&gt;Two characters fixed it. The first run afterwards reads 12.962, where before it would have said 13.0.&lt;/p&gt;

&lt;h4&gt;
  
  
  Launch the Instance
&lt;/h4&gt;

&lt;p&gt;Capacity for the whole G5g family was exhausted across all four availability zones several times, so the launcher cycles them with a sixty second backoff.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;[12:51:45] round 5 us-east-1c: ❌ AWS InsufficientInstanceCapacity
[12:52:47] us-east-1a: ✅ Launching `i-02e79988a6cbeecbf` (g5g.2xlarge, spot, 1x T4G) in `us-east-1`.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The walkthrough from here follows the PyTorch rig on &lt;code&gt;i-02e79988a6cbeecbf&lt;/code&gt;; the other two run the same steps against their own instances. All three landed in &lt;code&gt;us-east-1a&lt;/code&gt; within hours of each other. Note that AWS names the other zones as available in every one of those errors — that text describes on-demand capacity and says nothing about spot.&lt;/p&gt;

&lt;h4&gt;
  
  
  Watch the Install
&lt;/h4&gt;

&lt;p&gt;Cloud-init installs the runtime and then backgrounds itself, so the progress tool reports cloud-init's own state as well as the install log. A dead bootstrap and a slow one must not render identically.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;get_install_progress i-02e79988a6cbeecbf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;INSTALL COMPLETE&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt; &lt;span class="s"&gt;cloud-init ---&lt;/span&gt;
&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;done&lt;/span&gt;
&lt;span class="na"&gt;errors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a wheel install, not a build. Across the three timed boots the install finishes a median 113.55 s after launch, against the hours the vLLM rig needs for a from-source build.&lt;/p&gt;

&lt;h4&gt;
  
  
  Verify the GPU
&lt;/h4&gt;

&lt;p&gt;A config flag being accepted proves nothing, so the probe runs a real matmul on the device.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;verify_gpu_arch i-02e79988a6cbeecbf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;NVIDIA&lt;/span&gt; &lt;span class="n"&gt;T4G&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;7.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;15360&lt;/span&gt; &lt;span class="n"&gt;MiB&lt;/span&gt;
&lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;2.12&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;cu132&lt;/span&gt;
&lt;span class="n"&gt;arch_list&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sm_75&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sm_80&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sm_90&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sm_100&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sm_110&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sm_120&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;capability&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;compute_dtype&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;float16&lt;/span&gt;
&lt;span class="n"&gt;fp16&lt;/span&gt; &lt;span class="n"&gt;matmul&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;

&lt;span class="err"&gt;✅&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt; &lt;span class="n"&gt;reached&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;GPU&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;real&lt;/span&gt; &lt;span class="n"&gt;fp16&lt;/span&gt; &lt;span class="n"&gt;matmul&lt;/span&gt; &lt;span class="n"&gt;executed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The DLAMI's torch carries &lt;code&gt;sm_75&lt;/code&gt;. Upstream PyPI aarch64 wheels do not, so a &lt;code&gt;pip install torch&lt;/code&gt; on this box would serve on CPU without saying so.&lt;/p&gt;

&lt;h4&gt;
  
  
  Deploy the Server
&lt;/h4&gt;

&lt;p&gt;The payload is the rig's own source, shipped over SSM as a gzipped tarball because user data caps at 16 KiB.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;deploy_torch_server i-02e79988a6cbeecbf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;✅ Deployed 3 files (16 KiB base64) to &lt;span class="sb"&gt;`i-02e79988a6cbeecbf`&lt;/span&gt;.

Payload root: &lt;span class="sb"&gt;`/home/xbill/gemma4-dev/gpu-pytorch-g5g-2b`&lt;/span&gt;
Build id: &lt;span class="sb"&gt;`060a572aeb55`&lt;/span&gt; — verify_model_health checks the running server reports this.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Verify the Installation
&lt;/h4&gt;

&lt;p&gt;A non-empty reply is not evidence of health. One sibling was once measured answering &lt;code&gt;': ok: ok: ok…'&lt;/code&gt;, so the check reads the server's own degenerate-response counter either side of its probe, and compares the served build id against the local payload.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;verify_model_health i-02e79988a6cbeecbf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;✅ health=200 tokens=5 reply='ok'
&lt;span class="p"&gt;
-&lt;/span&gt; Degenerate (server's own verdict on the full text): &lt;span class="gs"&gt;**no**&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Build id served: &lt;span class="sb"&gt;`060a572aeb55`&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Build id matches the local payload (&lt;span class="sb"&gt;`060a572aeb55`&lt;/span&gt;).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Run the Sweep
&lt;/h4&gt;

&lt;p&gt;The same command runs against all three rigs. Only the endpoint changes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 sweep.py &lt;span class="nt"&gt;--base&lt;/span&gt; http://&amp;lt;ip&amp;gt;:8000/v1 &lt;span class="nt"&gt;--out&lt;/span&gt; benchmarks/runs/&amp;lt;run&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--contexts&lt;/span&gt; 64,512,1024,2048,3072,3800 &lt;span class="nt"&gt;--outputs&lt;/span&gt; 32,128 &lt;span class="nt"&gt;--repeats&lt;/span&gt; 3 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--decode-source&lt;/span&gt; both
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;decode-source: both -&amp;gt; both
ctx~512 out=32: in=633 out=32 decode=10.96 tok/s  e2e=9.83 tok/s (warmup 11.08)  stream/usage=0.9616
ctx~2048 out=128: in=2501 out=89 decode=10.66 tok/s  e2e=9.16 tok/s (warmup 10.61)  stream/usage=0.9540
ctx~3800 out=32: FAILED HTTP Error 400 {"detail":"prompt is 4630 tokens and the context
  bound is 4096, leaving no room to decode. Start the server with a larger --seq."}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cells that cannot exist on the hardware are recorded as &lt;code&gt;infeasible&lt;/code&gt; rather than dropped. An absent cell is indistinguishable from an untried one, which is how a sweep overstates its own coverage.&lt;/p&gt;

&lt;h4&gt;
  
  
  Decode at Concurrency One
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;runtime&lt;/th&gt;
&lt;th&gt;decode tok/s&lt;/th&gt;
&lt;th&gt;% of ceiling&lt;/th&gt;
&lt;th&gt;vs PyTorch&lt;/th&gt;
&lt;th&gt;cells&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;🥇&lt;/td&gt;
&lt;td&gt;vLLM v0.27.2rc0&lt;/td&gt;
&lt;td&gt;32.53&lt;/td&gt;
&lt;td&gt;53.0%&lt;/td&gt;
&lt;td&gt;3.18x&lt;/td&gt;
&lt;td&gt;12/12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🥈&lt;/td&gt;
&lt;td&gt;JAX&lt;/td&gt;
&lt;td&gt;12.69&lt;/td&gt;
&lt;td&gt;20.7%&lt;/td&gt;
&lt;td&gt;1.24x&lt;/td&gt;
&lt;td&gt;10/12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🥉&lt;/td&gt;
&lt;td&gt;PyTorch + transformers&lt;/td&gt;
&lt;td&gt;10.24&lt;/td&gt;
&lt;td&gt;16.7%&lt;/td&gt;
&lt;td&gt;1.00x&lt;/td&gt;
&lt;td&gt;10/12&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h4&gt;
  
  
  How Close Is That to the Hardware?
&lt;/h4&gt;

&lt;p&gt;The ceiling is arithmetic, not a measurement. E2B streams 4.514 GB of weights per decode step against a measured 277 GB/s, giving 16.30 ms per step and &lt;strong&gt;61.4 tok/s&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The PLE table is excluded from that figure because it is a gather and never a matmul. Quantising it from 9.257 GB to 5.752 GB moved decode by 0.00 tok/s across three cells, which is what confirms it never streams.&lt;/p&gt;

&lt;p&gt;All three runtimes sit far below the ceiling, so &lt;strong&gt;none of them is bandwidth-bound at batch one&lt;/strong&gt;. The PyTorch profile shows why: about 5,650 kernel launches per step at one to three microseconds each, on a chip whose launch overhead is five to ten.&lt;/p&gt;

&lt;h4&gt;
  
  
  The Number I Did Not Expect
&lt;/h4&gt;

&lt;p&gt;Time to first token was the result of the run, right up until it was checked.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;input tok&lt;/th&gt;
&lt;th&gt;vLLM&lt;/th&gt;
&lt;th&gt;JAX&lt;/th&gt;
&lt;th&gt;PyTorch&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;92&lt;/td&gt;
&lt;td&gt;103 ms&lt;/td&gt;
&lt;td&gt;225 ms&lt;/td&gt;
&lt;td&gt;164 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,259&lt;/td&gt;
&lt;td&gt;118 ms&lt;/td&gt;
&lt;td&gt;1,615 ms&lt;/td&gt;
&lt;td&gt;657 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3,746&lt;/td&gt;
&lt;td&gt;178 ms&lt;/td&gt;
&lt;td&gt;5,352 ms&lt;/td&gt;
&lt;td&gt;2,339 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A 30x advantage, far larger than the 3.2x on decode. It is also impossible. Prefill at 3,746 tokens is roughly 14 TFLOP against a T4G's realistic 20 to 30 TFLOP/s, which is 460 ms at best. vLLM's row says 178 ms.&lt;/p&gt;

&lt;p&gt;It did not. vLLM ships &lt;code&gt;enable_prefix_caching=True&lt;/code&gt;, and its own metrics say so:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"^vllm:prefix_cache_(queries|hits)_total"&lt;/span&gt; metrics.prom
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight prometheus"&gt;&lt;code&gt;&lt;span class="n"&gt;vllm:prefix_cache_queries_total&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;engine&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"0"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="mf"&gt;102898.0&lt;/span&gt;
&lt;span class="n"&gt;vllm:prefix_cache_hits_total&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;engine&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"0"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;     &lt;span class="mf"&gt;97440.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;A 94.7 percent hit rate.&lt;/strong&gt; vLLM genuinely prefilled 5.3 percent of the tokens it was sent, because the harness reused one prompt for a cell's warm-up and all three repeats. Neither sibling has a prefix cache, so both paid full prefill every time.&lt;/p&gt;

&lt;p&gt;The fix places a nonce first in the prompt, since a shared prefix is exactly what the cache keys on and a trailing nonce would not have defeated it. That property is now a unit test.&lt;/p&gt;

&lt;h4&gt;
  
  
  What the Prefill Data Does Support
&lt;/h4&gt;

&lt;p&gt;Strip the contaminated column and a real result remains. Neither of the other two runtimes caches prefixes, and both saw identical prompts.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;TTFT slope&lt;/th&gt;
&lt;th&gt;at 3,746 tokens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;JAX&lt;/td&gt;
&lt;td&gt;1.403 ms/token&lt;/td&gt;
&lt;td&gt;5,352 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PyTorch&lt;/td&gt;
&lt;td&gt;0.595 ms/token&lt;/td&gt;
&lt;td&gt;2,339 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;JAX prefills 2.4x slower than PyTorch&lt;/strong&gt;, consistently across all five shared context lengths. On an interactive workload with real context that dominates user-visible latency, and it runs in the opposite direction to the 1.24x decode advantage the same rig enjoys.&lt;/p&gt;

&lt;h4&gt;
  
  
  Boot Time Reverses the Ranking
&lt;/h4&gt;

&lt;p&gt;Nine cold boots, three per runtime, plus nine warm reloads. The start line is the moment &lt;code&gt;run_instances&lt;/code&gt; returns an id, because capacity wait measures AWS rather than the rig.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;runtime&lt;/th&gt;
&lt;th&gt;cold boot&lt;/th&gt;
&lt;th&gt;spread&lt;/th&gt;
&lt;th&gt;warm reload&lt;/th&gt;
&lt;th&gt;cold/warm&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;🥇&lt;/td&gt;
&lt;td&gt;PyTorch&lt;/td&gt;
&lt;td&gt;195.2 s&lt;/td&gt;
&lt;td&gt;11.8%&lt;/td&gt;
&lt;td&gt;24.5 s&lt;/td&gt;
&lt;td&gt;8.0x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🥈&lt;/td&gt;
&lt;td&gt;JAX&lt;/td&gt;
&lt;td&gt;242.2 s&lt;/td&gt;
&lt;td&gt;11.5%&lt;/td&gt;
&lt;td&gt;74.1 s&lt;/td&gt;
&lt;td&gt;3.3x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🥉&lt;/td&gt;
&lt;td&gt;vLLM&lt;/td&gt;
&lt;td&gt;1417.8 s&lt;/td&gt;
&lt;td&gt;12.6%&lt;/td&gt;
&lt;td&gt;264.3 s&lt;/td&gt;
&lt;td&gt;5.4x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;vLLM takes 23m 38s to serve, from a prebuilt AMI that downloads nothing.&lt;/strong&gt; PyTorch installs its runtime from wheels and pulls the 9.54 GiB checkpoint over the network, and is still 7.3x faster.&lt;/p&gt;

&lt;p&gt;Boot variance is 11.5, 11.8 and 12.6 percent — too consistent across three different runtimes to be a property of any of them. This family's decode noise floor is 1.7 percent, measured by running an identical build on two hosts, so boot is about seven times noisier and a single boot measurement is close to worthless.&lt;/p&gt;

&lt;h4&gt;
  
  
  Is Health 200 the Same as Ready?
&lt;/h4&gt;

&lt;p&gt;Not on every runtime, which is why the harness records two stop lines.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;runtime&lt;/th&gt;
&lt;th&gt;first completion, cold&lt;/th&gt;
&lt;th&gt;warm&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;vLLM&lt;/td&gt;
&lt;td&gt;0.5 s&lt;/td&gt;
&lt;td&gt;0.2 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PyTorch&lt;/td&gt;
&lt;td&gt;1.0 s&lt;/td&gt;
&lt;td&gt;0.7 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JAX&lt;/td&gt;
&lt;td&gt;22.9 s&lt;/td&gt;
&lt;td&gt;9.2 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;JAX returns health 200 and then compiles XLA per shape bucket on the first real request. Quoting health alone understates its time to serving by 22 seconds, and the cost does not vanish when warm. vLLM is the mirror image: slowest to boot, fastest first token, because graph capture is paid before the port binds.&lt;/p&gt;

&lt;h4&gt;
  
  
  What Does a Code Change Cost?
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;runtime&lt;/th&gt;
&lt;th&gt;to change serving code&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PyTorch&lt;/td&gt;
&lt;td&gt;ship 3 files over SSM, restart — 25 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JAX&lt;/td&gt;
&lt;td&gt;same mechanism, plus 9.2 s of compile — 83 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;vLLM&lt;/td&gt;
&lt;td&gt;no deploy path exists: rebuild from source, ~67 min, and reapply an out-of-tree Turing patch&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;vLLM's 264 s warm figure is a &lt;code&gt;systemctl restart&lt;/code&gt;, not a code change, so it flatters the comparison. &lt;strong&gt;vLLM wins decode 3.2x and loses the iteration loop by 3 to 100x.&lt;/strong&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  Five Theories About 546 Seconds
&lt;/h4&gt;

&lt;p&gt;vLLM's cold boot is dominated by weight loading: 468 to 561 seconds across four measurements. Explaining it took five attempts, four of which were wrong.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;em&gt;&lt;code&gt;g5g.2xlarge&lt;/code&gt; needs no swapfile and buys that time back.&lt;/em&gt; The rig's own documentation.
Falsified by the three-boot campaign at 23m 38s.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;A bigger host will not fix it.&lt;/em&gt; Retracted the same day — no large host had been measured.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;A larger host would very plausibly fix it.&lt;/em&gt; Falsified by one &lt;code&gt;g5g.4xlarge&lt;/code&gt; boot: available
RAM 11.19 to 26.49 GiB, weight loading 546 to 468 s, and total boot 4.7 percent lower,
inside the noise band.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;It is the loader; vLLM's log says auto-prefetch is disabled on EXT4.&lt;/em&gt; Falsified by a
within-box A/B — 76.13 s as shipped against 75.12 s with
&lt;code&gt;--safetensors-load-strategy=prefetch&lt;/code&gt;, which is 1.3 percent and therefore nothing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What that last run did find is the useful part.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;weight load&lt;/th&gt;
&lt;th&gt;n&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;cold boot, fresh instance&lt;/td&gt;
&lt;td&gt;468-561 s&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;warm restart, same box&lt;/td&gt;
&lt;td&gt;32-76 s&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same volume, same filesystem, same engine, differing only in whether the blocks had been read once. 9.54 GiB in 468 s is about 21 MiB/s, which is absurd for gp3 steady state and ordinary for first-touch reads against a snapshot-backed volume.&lt;/p&gt;

&lt;p&gt;Theory five is EBS lazily hydrating the volume from the AMI snapshot, and it is written down as untested. Given the strike rate it does not get promoted by reasoning.&lt;/p&gt;

&lt;h4&gt;
  
  
  Why the First Campaign Was Thrown Away
&lt;/h4&gt;

&lt;p&gt;The first boot campaign was discarded and re-run. Two independent instances had reported 214.4 s and 125.1 s, identical to the tenth. That is not consistency. It is a five second poll quantising two similar boots onto the same tick.&lt;/p&gt;

&lt;p&gt;The data was not wrong; the campaign log shows 215 s and 216 s of wall clock. It was unusably coarse. Health polling went to half a second, and the next pair of boots came in at 216.90 s and 193.86 s — an 11.9 percent spread the old harness could not see.&lt;/p&gt;

&lt;h4&gt;
  
  
  And Price/Performance?
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;$/hr&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;g5g.2xlarge&lt;/code&gt; on-demand&lt;/td&gt;
&lt;td&gt;$0.556&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;spot, measured across four AZs&lt;/td&gt;
&lt;td&gt;$0.3813 - $0.4416&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The whole exercise — 19 instances, 4.06 hours of &lt;code&gt;g5g.2xlarge&lt;/code&gt; plus 0.38 of &lt;code&gt;g5g.4xlarge&lt;/code&gt;, a serving sweep, three cross-rig runs, nine boots and three A/B restarts — came to under $3. That is arithmetic rather than a bill: AWS drops terminated instances after an hour, so the derivation in &lt;code&gt;cost_derivation.md&lt;/code&gt; bounds it at $1.84 all-spot and $2.68 all-on-demand.&lt;/p&gt;

&lt;p&gt;That 26 to 46 percent premium is the entire spot versus on-demand decision on this hardware, which is to say there is not one. Try spot, fall back, keep working; the automatic fallback cost about $0.24 across a nine-boot campaign.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cheap hardware is what made the method possible, not merely affordable.&lt;/strong&gt; Discarding a completed campaign over a poll-interval bug cost twenty minutes and pennies. Where a run is expensive, the same discovery argues for shipping the numbers with a caveat instead.&lt;/p&gt;

&lt;h4&gt;
  
  
  AWS Services Used
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;service&lt;/th&gt;
&lt;th&gt;what it does here&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;EC2&lt;/td&gt;
&lt;td&gt;the &lt;code&gt;g5g.2xlarge&lt;/code&gt; instances, spot and on-demand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Systems Manager&lt;/td&gt;
&lt;td&gt;every remote command; there is no inbound SSH rule and no private key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Secrets Manager&lt;/td&gt;
&lt;td&gt;the Hugging Face token, fetched at boot into a root-only &lt;code&gt;EnvironmentFile&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IAM&lt;/td&gt;
&lt;td&gt;one instance profile, &lt;code&gt;AmazonSSMManagedInstanceCore&lt;/code&gt; plus read on that one secret&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EBS&lt;/td&gt;
&lt;td&gt;gp3 root volumes, and the AMI snapshot behind the vLLM boot mystery&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h4&gt;
  
  
  Clean Up
&lt;/h4&gt;

&lt;p&gt;Every instance is terminated as soon as its artifacts are captured. There is no built image to lose, only a pip install and a model cache.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terminate_g5g_instance i-02e79988a6cbeecbf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;🗑️ Terminating &lt;span class="sb"&gt;`i-02e79988a6cbeecbf`&lt;/span&gt;. Relaunch costs a pip install, not a build.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ec2 describe-instances &lt;span class="nt"&gt;--filters&lt;/span&gt; &lt;span class="s2"&gt;"Name=instance-state-name,Values=running"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Reservations[].Instances[?starts_with(InstanceType,`g5g`)].InstanceId'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; text | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"🟢 none running"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;🟢 none running
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  What This Does Not Cover
&lt;/h4&gt;

&lt;p&gt;Everything above is concurrency one, which makes it a latency comparison rather than a serving one. Continuous batching is vLLM's whole value proposition and it is untested here.&lt;/p&gt;

&lt;p&gt;Only vLLM can serve concurrently at all today. The PyTorch server holds an &lt;code&gt;asyncio.Lock&lt;/code&gt; with the comment &lt;em&gt;"one GPU, one process -&amp;gt; serialize requests"&lt;/em&gt;, and the JAX rig has no batching machinery whatever. The engine-level batch sweep suggests what is on the table — batch eight reaches 84.16 tok/s for an extra 0.258 GB, with per-step time growing two percent across an eight-fold batch — but that number never leaves the engine.&lt;/p&gt;

&lt;p&gt;Three further gaps: the JAX leg ran its shipped quantised configuration against two dense runtimes; no output-quality axis was measured at all, on a comparison where one runtime uses a deliberately lossy LM head; and every TTFT figure predates the prompt-uniqueness fix, so only the JAX versus PyTorch half of that table is sound.&lt;/p&gt;

&lt;h4&gt;
  
  
  Summary
&lt;/h4&gt;

&lt;p&gt;The goal of this article was to compare three inference runtimes on identical silicon without the harness being a variable. The key to the solution was a single client-side statistic that every OpenAI-compatible server can produce. The measured results were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Decode at concurrency one: vLLM 32.53, JAX 12.69, PyTorch 10.24 tok/s — 53, 21 and 17
percent of a 61.4 tok/s bandwidth ceiling, so none is bandwidth-bound.&lt;/li&gt;
&lt;li&gt;The ranking reverses on lifecycle. Cold boot 195.2 s for PyTorch against 1417.8 s for vLLM;
a code change costs 25 s against a from-source rebuild.&lt;/li&gt;
&lt;li&gt;JAX prefills 2.4x slower than PyTorch, 1.403 against 0.595 ms/token.&lt;/li&gt;
&lt;li&gt;A 94.7 percent prefix-cache hit rate turned a 30x TTFT result into a harness artifact.&lt;/li&gt;
&lt;li&gt;Calibration offsets are per-rig, 0.9799 and 0.9543, and are not transferable.&lt;/li&gt;
&lt;li&gt;Boot variance is about 12 percent on this platform regardless of runtime.&lt;/li&gt;
&lt;li&gt;The whole exercise cost under $3 in total. That is what made discarding a finished campaign
over a poll-interval bug a twenty minute decision rather than an argument.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scope: the decode and boot numbers are one &lt;code&gt;g5g.2xlarge&lt;/code&gt; per runtime in &lt;code&gt;us-east-1a&lt;/code&gt; on 2026-08-31, three repeats per cell and three repeats per boot, mixed spot and on-demand with the market recorded per run. Three things differed between the legs and are named where they matter: vLLM ran &lt;code&gt;max_model_len&lt;/code&gt; 16384 against 4096 for the other two; the JAX leg ran its shipped quantised configuration against two dense runtimes; and vLLM booted from a prebuilt AMI carrying its model cache while the other two installed from wheels and downloaded the checkpoint, which is the point of the boot comparison rather than a flaw in it. Two runs sit outside that envelope and say so in the text: the RAM test was a single &lt;code&gt;g5g.4xlarge&lt;/code&gt;, and the prefetch A/B ran 2026-09-01 on on-demand after a spot reclamation killed the first attempt. Decode is unaffected by the prefix-cache issue, which changes prefill only.&lt;/p&gt;

&lt;p&gt;The strategy for using MCP for multi-runtime comparison was validated with a incremental step by step approach.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>machinelearning</category>
      <category>benchmarking</category>
      <category>python</category>
    </item>
    <item>
      <title>Streamline Publishing with a Claude Code Skill</title>
      <dc:creator>xbill</dc:creator>
      <pubDate>Tue, 01 Sep 2026 14:37:19 +0000</pubDate>
      <link>https://dev.to/aws-builders/streamline-publishing-with-a-claude-code-skill-an4</link>
      <guid>https://dev.to/aws-builders/streamline-publishing-with-a-claude-code-skill-an4</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; &lt;a href="https://github.com/xbill9/publishing-kit" rel="noopener noreferrer"&gt;publishing-kit&lt;/a&gt; packages the whole publishing lifecycle as a Claude Code skill. Write one markdown file, and it builds the dev.to, AWS Builder Center, Medium and LinkedIn versions, checks them, and posts the ones that have an API. This article, its cover and all four of its artifacts were produced by the thing the article is about — dogfooding all the way down. More on that at the end.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Publishing one technical article to four places involves a surprising amount of ceremony: making a cover at whatever size each destination wants, rendering tables to images because Medium's importer eats them, stripping emoji for AWS, checking that every number in the piece came from a real run, getting a long markdown file into a browser editor that has no API, remembering which organization the article routes to, and writing the announcement post afterwards — by which point you have four slightly different files and no idea which one is current.&lt;/p&gt;

&lt;p&gt;I packaged all of that into &lt;strong&gt;&lt;a href="https://github.com/xbill9/publishing-kit" rel="noopener noreferrer"&gt;publishing-kit&lt;/a&gt;&lt;/strong&gt; — a Claude Code skill and a set of small scripts — so you can just ask Claude to publish.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;The skill teaches Claude the publishing lifecycle; the scripts do the parts a language model should not be doing by hand.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Build the artifacts:&lt;/strong&gt; one source file becomes the dev.to markdown, the Builder Center
version with emoji stripped and the AWS disclaimer appended, Medium HTML with every table
rendered to a PNG, and a LinkedIn post. &lt;code&gt;make-builder.py&lt;/code&gt;, &lt;code&gt;make-medium.py&lt;/code&gt;,
&lt;code&gt;make-linkedin.py&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the cover once:&lt;/strong&gt; &lt;code&gt;make-cover.py --flow --sizes devto,builder&lt;/code&gt; draws the pipeline as an
illustration and renders it at every geometry the destinations demand, names the file by a hash
of its own bytes, and reports which type sizes survive a 320px feed card — which is the size a
cover is actually met at, and where a diagram with small labels turns to mush.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trace every number:&lt;/strong&gt; &lt;code&gt;check-facts.py&lt;/code&gt; pulls the prices, measurements and versions out of
your prose and reports which ones appear in no evidence file. It cannot tell you a figure is
true. It tells you which ones you are asserting from memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pre-flight:&lt;/strong&gt; &lt;code&gt;preflight.py --live&lt;/code&gt; runs the lot and exits non-zero — cover committed and
matching HEAD, geometry right, front matter complete, no hard-wrapped paragraphs, and every
published URL fetched and compared byte for byte against your disk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Post where there is an API:&lt;/strong&gt; &lt;code&gt;publish-devto.py --create&lt;/code&gt; takes the front matter as the
payload, so title, tags and cover ride along with the body, and &lt;code&gt;--org-slug&lt;/code&gt; routes it to a
community channel. No browser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drive the editors that have none:&lt;/strong&gt; AWS Builder Center and Medium are Chrome work, and the
skill knows the route — payload through &lt;code&gt;window.name&lt;/code&gt;, a checksum on both sides, an emptiness
assertion before the paste, and a landmark count after it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It also encodes the hard-won details you would otherwise learn the day after publishing: that dev.to renders markdown with hard breaks &lt;strong&gt;on&lt;/strong&gt;, so a source wrapped at 95 columns arrives with a stray break in every paragraph; that dev.to does not host your cover but proxies it at 2.381:1, so a 1376x768 cover loses 95px off the top and bottom; that Medium drops &lt;code&gt;data:&lt;/code&gt; URI images on paste, so the self-contained build arrives with no pictures at all; and that LinkedIn's Posts API cannot create a draft, because &lt;code&gt;PUBLISHED&lt;/code&gt; is the only state it accepts on creation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;

&lt;p&gt;The fastest path is the plugin marketplace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/plugin marketplace add xbill9/publishing-kit
/plugin &lt;span class="nb"&gt;install &lt;/span&gt;publishing@publishing-kit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prefer the classic route? Clone it and symlink the skill:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/xbill9/publishing-kit
&lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="s2"&gt;/publishing-kit/skills/publishing"&lt;/span&gt; ~/.claude/skills/publishing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You will need Python 3 with Pillow for the cover and table rendering, a dev.to API key in &lt;code&gt;~/.devto.key&lt;/code&gt;, and a public repo to hold the article directory — both the cover and the Medium images are fetched by URL when the page renders, so unpushed means broken.&lt;/p&gt;

&lt;p&gt;Mostly you will not run these scripts yourself — Claude does, and &lt;code&gt;SKILL.md&lt;/code&gt; tells it where they are. When you do want one by hand, the paths differ by install method, so ask rather than remember: &lt;code&gt;skill-footprint.py --where&lt;/code&gt; prints the skill directory. From a clone it is &lt;code&gt;skills/publishing&lt;/code&gt;; from a marketplace install it sits under a &lt;strong&gt;version-numbered&lt;/strong&gt; cache path, which means no invocation you write down survives an upgrade.&lt;/p&gt;

&lt;p&gt;That version numbering matters if you plan to hack on the skill itself: installing takes a &lt;strong&gt;snapshot&lt;/strong&gt;, and &lt;code&gt;claude plugin update&lt;/code&gt; compares version strings, so edits under the same version never reach your session. Symlink while you are iterating.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like in practice
&lt;/h2&gt;

&lt;p&gt;Once installed, you talk to Claude Code like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Write this benchmark up and publish it to dev.to under aws-builders"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Claude writes the source article, generates the cover at both geometries, traces the numbers against your run artifacts, runs the pre-flight, tells you what failed, and — once it passes — posts it as a draft and hands you the link. Then:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Now make the Medium and Builder Center versions"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It derives them, renders the tables to images, opens Chrome, and fills the editors. It stops at draft on every destination and hands back links. Publishing is your keystroke, not its.&lt;/p&gt;

&lt;h2&gt;
  
  
  Debugging a publish that went sideways
&lt;/h2&gt;

&lt;p&gt;This is the half that surprised me most, and where most of the skill's value ended up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The page does not look like the file.&lt;/strong&gt; Fetch what the destination actually serves rather than reasoning about what you pushed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 ../../skills/publishing/scripts/check-links.py article.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;devto-publishing-kit.md  (branch URLs)
  ok    cover.77acc7c4.jpg: HTTP 200, bytes match disk
  ok    img/cover.77acc7c4.jpg: HTTP 200, bytes match disk
  ok    img/devto-publishing-kit-table-1.png: HTTP 200, bytes match disk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;FAIL&lt;/code&gt; there reads "HTTP 200 but the served bytes differ from disk", which is usually an image regenerated after its commit. Every other check in the kit reasons about local state — is the file there, is it tracked — and each of those can pass while the published URL serves something else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The paragraphs look ragged.&lt;/strong&gt; &lt;code&gt;check-article.py&lt;/code&gt; reports hard-wrapped paragraphs with the line number of the first one, and &lt;code&gt;publish-devto.py&lt;/code&gt; unwraps on the way out so your repo copy stays readable at 95 columns and the published page does not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An image is missing on Medium.&lt;/strong&gt; You pasted &lt;code&gt;-embed.html&lt;/code&gt;. Paste &lt;code&gt;-hosted.html&lt;/code&gt;, which references real URLs Medium re-hosts, and commit the images first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A number in the article has no artifact behind it.&lt;/strong&gt; &lt;code&gt;check-facts.py&lt;/code&gt; will name it. Every untraced claim is one of three things: measured but never archived, arithmetic that should be labelled as arithmetic, or asserted from memory — and it is always the third one that turns out to be wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Under the hood
&lt;/h2&gt;

&lt;p&gt;One &lt;code&gt;SKILL.md&lt;/code&gt;, a reference file for each destination's quirks, and a script per job. The skill decides when each one runs; the scripts are independently runnable and print what they did.&lt;/p&gt;

&lt;p&gt;The bit worth stealing for your own skills is &lt;code&gt;references/house-style.md&lt;/code&gt;. Voice, section order, the opener and closing formulas live in that one file, and nothing in &lt;code&gt;SKILL.md&lt;/code&gt; or the scripts depends on it. Swap it and the kit writes as somebody else.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;skill-footprint.py&lt;/code&gt; measures the skill's own size and token cost, and emits the cost table and cover footer for articles like this one — because a figure that changes on every commit does not belong in prose you have to remember to update.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dogfooding: this article, and its cover 🐕🍖
&lt;/h2&gt;

&lt;p&gt;Every artifact here came out of the kit. The cover was rendered by &lt;code&gt;make-cover.py&lt;/code&gt;, the Builder Center version was derived by &lt;code&gt;make-builder.py&lt;/code&gt;, the Medium build by &lt;code&gt;make-medium.py&lt;/code&gt;, and the dev.to draft posted by &lt;code&gt;publish-devto.py&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The two numbers on the cover are real, and both were found by using the kit on itself:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;measured&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;dev.to, hard-wrapped paragraphs&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;47 of 62&lt;/strong&gt; in a published article carried a stray break&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Medium, images pasted as data URIs&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;0 of 4&lt;/strong&gt; survived; from real URLs, 4 of 4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first one had been happening to my articles for months. The second cost a full re-do the first time it happened. Neither is in the docs of either destination, and both are now checks.&lt;/p&gt;

&lt;p&gt;The run also found five faults in the kit itself, which is the point of eating your own cooking: &lt;code&gt;make-medium.py&lt;/code&gt; had another project's repo hardcoded as its default image base; &lt;code&gt;check-article.py&lt;/code&gt; passed a cover that was tracked but regenerated; &lt;code&gt;make-cover.py&lt;/code&gt; lost a &lt;code&gt;--tile&lt;/code&gt; value beginning with a hyphen to &lt;code&gt;argparse&lt;/code&gt;; &lt;code&gt;check-facts.py&lt;/code&gt; read &lt;code&gt;127.0.0.1&lt;/code&gt; as a version number; and &lt;code&gt;unwrap()&lt;/code&gt; skipped block quotes, so this article's own TL;DR posted as five separate lines — while the check that should have caught it skipped block quotes too, and agreed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Repo:&lt;/strong&gt; &lt;a href="https://github.com/xbill9/publishing-kit" rel="noopener noreferrer"&gt;github.com/xbill9/publishing-kit&lt;/a&gt; (Apache-2.0)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The skill:&lt;/strong&gt; &lt;a href="https://github.com/xbill9/publishing-kit/blob/main/skills/publishing/SKILL.md" rel="noopener noreferrer"&gt;&lt;code&gt;skills/publishing/SKILL.md&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This article's directory&lt;/strong&gt;, with every artifact and the evidence behind every number:
&lt;a href="https://github.com/xbill9/publishing-kit/tree/main/articles/publishing-kit-skill" rel="noopener noreferrer"&gt;&lt;code&gt;articles/publishing-kit-skill/&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude Code:&lt;/strong&gt; &lt;a href="https://claude.com/claude-code" rel="noopener noreferrer"&gt;claude.com/claude-code&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Issues and PRs welcome. This is a third-party community project, not affiliated with dev.to, Medium, AWS or LinkedIn — and the destination behaviours described here were measured on 2026-08-31, so check them again before you trust them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>writing</category>
      <category>devtools</category>
      <category>ai</category>
    </item>
    <item>
      <title>Streamline Publishing with a Claude Code Skill</title>
      <dc:creator>xbill</dc:creator>
      <pubDate>Tue, 01 Sep 2026 14:37:08 +0000</pubDate>
      <link>https://dev.to/gde/streamline-publishing-with-a-claude-code-skill-1bdn</link>
      <guid>https://dev.to/gde/streamline-publishing-with-a-claude-code-skill-1bdn</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; &lt;a href="https://github.com/xbill9/publishing-kit" rel="noopener noreferrer"&gt;publishing-kit&lt;/a&gt; packages the whole publishing lifecycle as a Claude Code skill. Write one markdown file, and it builds the dev.to, AWS Builder Center, Medium and LinkedIn versions, checks them, and posts the ones that have an API. This article, its cover and all four of its artifacts were produced by the thing the article is about — dogfooding all the way down. More on that at the end.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Publishing one technical article to four places involves a surprising amount of ceremony: making a cover at whatever size each destination wants, rendering tables to images because Medium's importer eats them, stripping emoji for AWS, checking that every number in the piece came from a real run, getting a long markdown file into a browser editor that has no API, remembering which organization the article routes to, and writing the announcement post afterwards — by which point you have four slightly different files and no idea which one is current.&lt;/p&gt;

&lt;p&gt;I packaged all of that into &lt;strong&gt;&lt;a href="https://github.com/xbill9/publishing-kit" rel="noopener noreferrer"&gt;publishing-kit&lt;/a&gt;&lt;/strong&gt; — a Claude Code skill and a set of small scripts — so you can just ask Claude to publish.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;The skill teaches Claude the publishing lifecycle; the scripts do the parts a language model should not be doing by hand.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Build the artifacts:&lt;/strong&gt; one source file becomes the dev.to markdown, the Builder Center
version with emoji stripped and the AWS disclaimer appended, Medium HTML with every table
rendered to a PNG, and a LinkedIn post. &lt;code&gt;make-builder.py&lt;/code&gt;, &lt;code&gt;make-medium.py&lt;/code&gt;,
&lt;code&gt;make-linkedin.py&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the cover once:&lt;/strong&gt; &lt;code&gt;make-cover.py --flow --sizes devto,builder&lt;/code&gt; draws the pipeline as an
illustration and renders it at every geometry the destinations demand, names the file by a hash
of its own bytes, and reports which type sizes survive a 320px feed card — which is the size a
cover is actually met at, and where a diagram with small labels turns to mush.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trace every number:&lt;/strong&gt; &lt;code&gt;check-facts.py&lt;/code&gt; pulls the prices, measurements and versions out of
your prose and reports which ones appear in no evidence file. It cannot tell you a figure is
true. It tells you which ones you are asserting from memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pre-flight:&lt;/strong&gt; &lt;code&gt;preflight.py --live&lt;/code&gt; runs the lot and exits non-zero — cover committed and
matching HEAD, geometry right, front matter complete, no hard-wrapped paragraphs, and every
published URL fetched and compared byte for byte against your disk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Post where there is an API:&lt;/strong&gt; &lt;code&gt;publish-devto.py --create&lt;/code&gt; takes the front matter as the
payload, so title, tags and cover ride along with the body, and &lt;code&gt;--org-slug&lt;/code&gt; routes it to a
community channel. No browser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drive the editors that have none:&lt;/strong&gt; AWS Builder Center and Medium are Chrome work, and the
skill knows the route — payload through &lt;code&gt;window.name&lt;/code&gt;, a checksum on both sides, an emptiness
assertion before the paste, and a landmark count after it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It also encodes the hard-won details you would otherwise learn the day after publishing: that dev.to renders markdown with hard breaks &lt;strong&gt;on&lt;/strong&gt;, so a source wrapped at 95 columns arrives with a stray break in every paragraph; that dev.to does not host your cover but proxies it at 2.381:1, so a 1376x768 cover loses 95px off the top and bottom; that Medium drops &lt;code&gt;data:&lt;/code&gt; URI images on paste, so the self-contained build arrives with no pictures at all; and that LinkedIn's Posts API cannot create a draft, because &lt;code&gt;PUBLISHED&lt;/code&gt; is the only state it accepts on creation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;

&lt;p&gt;The fastest path is the plugin marketplace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/plugin marketplace add xbill9/publishing-kit
/plugin &lt;span class="nb"&gt;install &lt;/span&gt;publishing@publishing-kit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prefer the classic route? Clone it and symlink the skill:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/xbill9/publishing-kit
&lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="s2"&gt;/publishing-kit/skills/publishing"&lt;/span&gt; ~/.claude/skills/publishing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You will need Python 3 with Pillow for the cover and table rendering, a dev.to API key in &lt;code&gt;~/.devto.key&lt;/code&gt;, and a public repo to hold the article directory — both the cover and the Medium images are fetched by URL when the page renders, so unpushed means broken.&lt;/p&gt;

&lt;p&gt;Mostly you will not run these scripts yourself — Claude does, and &lt;code&gt;SKILL.md&lt;/code&gt; tells it where they are. When you do want one by hand, the paths differ by install method, so ask rather than remember: &lt;code&gt;skill-footprint.py --where&lt;/code&gt; prints the skill directory. From a clone it is &lt;code&gt;skills/publishing&lt;/code&gt;; from a marketplace install it sits under a &lt;strong&gt;version-numbered&lt;/strong&gt; cache path, which means no invocation you write down survives an upgrade.&lt;/p&gt;

&lt;p&gt;That version numbering matters if you plan to hack on the skill itself: installing takes a &lt;strong&gt;snapshot&lt;/strong&gt;, and &lt;code&gt;claude plugin update&lt;/code&gt; compares version strings, so edits under the same version never reach your session. Symlink while you are iterating.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like in practice
&lt;/h2&gt;

&lt;p&gt;Once installed, you talk to Claude Code like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Write this benchmark up and publish it to dev.to under aws-builders"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Claude writes the source article, generates the cover at both geometries, traces the numbers against your run artifacts, runs the pre-flight, tells you what failed, and — once it passes — posts it as a draft and hands you the link. Then:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Now make the Medium and Builder Center versions"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It derives them, renders the tables to images, opens Chrome, and fills the editors. It stops at draft on every destination and hands back links. Publishing is your keystroke, not its.&lt;/p&gt;

&lt;h2&gt;
  
  
  Debugging a publish that went sideways
&lt;/h2&gt;

&lt;p&gt;This is the half that surprised me most, and where most of the skill's value ended up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The page does not look like the file.&lt;/strong&gt; Fetch what the destination actually serves rather than reasoning about what you pushed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 ../../skills/publishing/scripts/check-links.py article.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;devto-publishing-kit.md  (branch URLs)
  ok    cover.77acc7c4.jpg: HTTP 200, bytes match disk
  ok    img/cover.77acc7c4.jpg: HTTP 200, bytes match disk
  ok    img/devto-publishing-kit-table-1.png: HTTP 200, bytes match disk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;FAIL&lt;/code&gt; there reads "HTTP 200 but the served bytes differ from disk", which is usually an image regenerated after its commit. Every other check in the kit reasons about local state — is the file there, is it tracked — and each of those can pass while the published URL serves something else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The paragraphs look ragged.&lt;/strong&gt; &lt;code&gt;check-article.py&lt;/code&gt; reports hard-wrapped paragraphs with the line number of the first one, and &lt;code&gt;publish-devto.py&lt;/code&gt; unwraps on the way out so your repo copy stays readable at 95 columns and the published page does not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An image is missing on Medium.&lt;/strong&gt; You pasted &lt;code&gt;-embed.html&lt;/code&gt;. Paste &lt;code&gt;-hosted.html&lt;/code&gt;, which references real URLs Medium re-hosts, and commit the images first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A number in the article has no artifact behind it.&lt;/strong&gt; &lt;code&gt;check-facts.py&lt;/code&gt; will name it. Every untraced claim is one of three things: measured but never archived, arithmetic that should be labelled as arithmetic, or asserted from memory — and it is always the third one that turns out to be wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Under the hood
&lt;/h2&gt;

&lt;p&gt;One &lt;code&gt;SKILL.md&lt;/code&gt;, a reference file for each destination's quirks, and a script per job. The skill decides when each one runs; the scripts are independently runnable and print what they did.&lt;/p&gt;

&lt;p&gt;The bit worth stealing for your own skills is &lt;code&gt;references/house-style.md&lt;/code&gt;. Voice, section order, the opener and closing formulas live in that one file, and nothing in &lt;code&gt;SKILL.md&lt;/code&gt; or the scripts depends on it. Swap it and the kit writes as somebody else.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;skill-footprint.py&lt;/code&gt; measures the skill's own size and token cost, and emits the cost table and cover footer for articles like this one — because a figure that changes on every commit does not belong in prose you have to remember to update.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dogfooding: this article, and its cover 🐕🍖
&lt;/h2&gt;

&lt;p&gt;Every artifact here came out of the kit. The cover was rendered by &lt;code&gt;make-cover.py&lt;/code&gt;, the Builder Center version was derived by &lt;code&gt;make-builder.py&lt;/code&gt;, the Medium build by &lt;code&gt;make-medium.py&lt;/code&gt;, and the dev.to draft posted by &lt;code&gt;publish-devto.py&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The two numbers on the cover are real, and both were found by using the kit on itself:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;measured&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;dev.to, hard-wrapped paragraphs&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;47 of 62&lt;/strong&gt; in a published article carried a stray break&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Medium, images pasted as data URIs&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;0 of 4&lt;/strong&gt; survived; from real URLs, 4 of 4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first one had been happening to my articles for months. The second cost a full re-do the first time it happened. Neither is in the docs of either destination, and both are now checks.&lt;/p&gt;

&lt;p&gt;The run also found five faults in the kit itself, which is the point of eating your own cooking: &lt;code&gt;make-medium.py&lt;/code&gt; had another project's repo hardcoded as its default image base; &lt;code&gt;check-article.py&lt;/code&gt; passed a cover that was tracked but regenerated; &lt;code&gt;make-cover.py&lt;/code&gt; lost a &lt;code&gt;--tile&lt;/code&gt; value beginning with a hyphen to &lt;code&gt;argparse&lt;/code&gt;; &lt;code&gt;check-facts.py&lt;/code&gt; read &lt;code&gt;127.0.0.1&lt;/code&gt; as a version number; and &lt;code&gt;unwrap()&lt;/code&gt; skipped block quotes, so this article's own TL;DR posted as five separate lines — while the check that should have caught it skipped block quotes too, and agreed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Repo:&lt;/strong&gt; &lt;a href="https://github.com/xbill9/publishing-kit" rel="noopener noreferrer"&gt;github.com/xbill9/publishing-kit&lt;/a&gt; (Apache-2.0)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The skill:&lt;/strong&gt; &lt;a href="https://github.com/xbill9/publishing-kit/blob/main/skills/publishing/SKILL.md" rel="noopener noreferrer"&gt;&lt;code&gt;skills/publishing/SKILL.md&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This article's directory&lt;/strong&gt;, with every artifact and the evidence behind every number:
&lt;a href="https://github.com/xbill9/publishing-kit/tree/main/articles/publishing-kit-skill" rel="noopener noreferrer"&gt;&lt;code&gt;articles/publishing-kit-skill/&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude Code:&lt;/strong&gt; &lt;a href="https://claude.com/claude-code" rel="noopener noreferrer"&gt;claude.com/claude-code&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Issues and PRs welcome. This is a third-party community project, not affiliated with dev.to, Medium, AWS or LinkedIn — and the destination behaviours described here were measured on 2026-08-31, so check them again before you trust them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>writing</category>
      <category>devtools</category>
      <category>ai</category>
    </item>
    <item>
      <title>g5g vs g6 for LLM Serving: the Same Code, and 3.7x the Throughput</title>
      <dc:creator>xbill</dc:creator>
      <pubDate>Mon, 31 Aug 2026 01:40:10 +0000</pubDate>
      <link>https://dev.to/aws-builders/g5g-vs-g6-for-llm-serving-the-same-code-and-37x-the-throughput-5a39</link>
      <guid>https://dev.to/aws-builders/g5g-vs-g6-for-llm-serving-the-same-code-and-37x-the-throughput-5a39</guid>
      <description>&lt;p&gt;This article compares two AWS GPU instance families for serving a small language&lt;br&gt;
model, using a payload that is byte-identical on both. The older family loses&lt;br&gt;
&lt;strong&gt;87% of decode&lt;/strong&gt; to dtype conversion, and nothing in any log, metric or health&lt;br&gt;
check says so.&lt;/p&gt;

&lt;p&gt;The code is here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/xbill9/gemma4-dev" rel="noopener noreferrer"&gt;https://github.com/xbill9/gemma4-dev&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The Two Instances
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;g5g.2xlarge&lt;/code&gt; pairs a Graviton2 (aarch64) host with an NVIDIA &lt;strong&gt;T4G&lt;/strong&gt; — Turing,&lt;br&gt;
SM 7.5. &lt;code&gt;g6.2xlarge&lt;/code&gt; is x86_64 with an NVIDIA &lt;strong&gt;L4&lt;/strong&gt; — Ada, SM 8.9. Both were run&lt;br&gt;
on spot.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;g5g.2xlarge&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;g6.2xlarge&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GPU&lt;/td&gt;
&lt;td&gt;NVIDIA &lt;strong&gt;T4G&lt;/strong&gt; — Turing, SM 7.5&lt;/td&gt;
&lt;td&gt;NVIDIA &lt;strong&gt;L4&lt;/strong&gt; — Ada, SM 8.9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU memory&lt;/td&gt;
&lt;td&gt;15,360 MiB&lt;/td&gt;
&lt;td&gt;23,034 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Host&lt;/td&gt;
&lt;td&gt;Graviton2, &lt;strong&gt;aarch64&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;x86_64&lt;/strong&gt;, &lt;code&gt;us-east-1d&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Purchase model&lt;/td&gt;
&lt;td&gt;spot&lt;/td&gt;
&lt;td&gt;spot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run cited&lt;/td&gt;
&lt;td&gt;&lt;code&gt;2026-08-28-full-run-cached-g5g&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;2026-08-28-first-serve-g6&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The workload is &lt;code&gt;google/gemma-4-E2B-it&lt;/code&gt;, the dense reference checkpoint, served&lt;br&gt;
through a hand-written pure-JAX port — no PyTorch, no vLLM, no &lt;code&gt;torch_xla&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The payload is byte-identical on both instances&lt;/strong&gt;: build id &lt;code&gt;51bc52c9e2e9&lt;/code&gt;,&lt;br&gt;
config &lt;code&gt;ple4 + int8_lm_head&lt;/code&gt;, and &lt;code&gt;tpu_jax_weight_bytes&lt;/code&gt; reading &lt;strong&gt;6,155,450,950&lt;/strong&gt;&lt;br&gt;
on each. Only the chip and its host differ.&lt;/p&gt;
&lt;h2&gt;
  
  
  At This Point You Should Have
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Spot capacity for &lt;code&gt;g5g.2xlarge&lt;/code&gt; and &lt;code&gt;g6.2xlarge&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A Hugging Face token with access to &lt;code&gt;google/gemma-4-E2B-it&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A Deep Learning base AMI matching the host architecture — the aarch64 and
x86_64 images are not interchangeable&lt;/li&gt;
&lt;li&gt;No CUDA toolkit and no Rust toolchain; &lt;code&gt;jax[cuda13]&lt;/code&gt; supplies CUDA as wheels&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Install Is a pip Install
&lt;/h2&gt;

&lt;p&gt;There is no build step on either instance. &lt;code&gt;jax[cuda13]&lt;/code&gt; ships wheels carrying&lt;br&gt;
CUDA, including aarch64 wheels for the Graviton2 host.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Install: 117 s, with the cache restore included
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;XLA's persistent compilation cache is pushed to S3 and restored on boot. On the&lt;br&gt;
g5g rig it restores &lt;strong&gt;805 files / 12 MB in 6 seconds&lt;/strong&gt; onto a fresh instance from&lt;br&gt;
a box that had already been terminated.&lt;/p&gt;
&lt;h2&gt;
  
  
  Warm Up Before You Measure Anything
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;max_new_tokens&lt;/code&gt; is a &lt;code&gt;static_argnames&lt;/code&gt; entry, so &lt;code&gt;(bucket, max_tokens)&lt;/code&gt; is the&lt;br&gt;
compiled shape. The first request off a fresh engine pays XLA compilation.&lt;/p&gt;

&lt;p&gt;On the g5g that first request took &lt;strong&gt;18.06 s against 4.50 s warm&lt;/strong&gt; — a 4.0x&lt;br&gt;
whole-request ratio, from &lt;code&gt;2026-08-21-cuda13-py314-g5g&lt;/code&gt;. A harness that skips&lt;br&gt;
warm-up misreports the instance by a factor of four.&lt;/p&gt;

&lt;p&gt;Note that the 56x figure quoted from the earlier first-serve baseline is &lt;strong&gt;TTFT&lt;br&gt;
specifically&lt;/strong&gt;, which is a different measurement and not interchangeable with the&lt;br&gt;
whole-request ratio.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Throughput Sweep
&lt;/h2&gt;

&lt;p&gt;64 output tokens, concurrency 1, 3 repeats per cell, median reported. "Gauge" is&lt;br&gt;
the engine's steady-state decode counter; "end-to-end" is wall time over the whole&lt;br&gt;
request including prefill.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Input tokens&lt;/th&gt;
&lt;th&gt;🥈 g5g gauge&lt;/th&gt;
&lt;th&gt;g5g end-to-end&lt;/th&gt;
&lt;th&gt;🥇 g6 gauge&lt;/th&gt;
&lt;th&gt;g6 end-to-end&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;41&lt;/td&gt;
&lt;td&gt;12.9 tok/s&lt;/td&gt;
&lt;td&gt;12.43 tok/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;48.5 tok/s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;46.23 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;521&lt;/td&gt;
&lt;td&gt;13.0 tok/s&lt;/td&gt;
&lt;td&gt;11.28 tok/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;48.4 tok/s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;42.87 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2,057&lt;/td&gt;
&lt;td&gt;12.9 tok/s&lt;/td&gt;
&lt;td&gt;8.22 tok/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;48.3 tok/s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;34.57 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3,593&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;48.3 tok/s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;27.55 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;3.7x on decode&lt;/strong&gt;, for the same code and the same weights.&lt;/p&gt;
&lt;h2&gt;
  
  
  Read the Gauge, Not End-To-End
&lt;/h2&gt;

&lt;p&gt;Decode moves 0.8% across a 50x context range on the g5g and 0.4% on the g6.&lt;br&gt;
End-to-end falls hard on both — 12.43 to 8.22, and 46.23 to 27.55.&lt;/p&gt;

&lt;p&gt;That fall is prefill being linear in the padded bucket, not decode degrading. They&lt;br&gt;
are two different claims, and conflating them makes a benchmark a lie.&lt;/p&gt;

&lt;p&gt;A cost proportional to the &lt;strong&gt;weights&lt;/strong&gt; rather than the context produces exactly&lt;br&gt;
this shape, which is why the KV cache is not what sets decode speed on either&lt;br&gt;
instance.&lt;/p&gt;

&lt;p&gt;Usable context on the g5g is &lt;code&gt;MAX_MODEL_LEN=4096&lt;/code&gt;, and that is the honest number:&lt;br&gt;
4,105 prompt tokens serve, 5,120 fails on a prefill transient.&lt;/p&gt;
&lt;h2&gt;
  
  
  Where the g5g's Decode Actually Went
&lt;/h2&gt;

&lt;p&gt;Profiling with xprof, 20 decode steps with the service stopped:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;🥈 g5g / T4G (SM 7.5)&lt;/th&gt;
&lt;th&gt;🥇 g6 / L4 (SM 8.9)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;dtype conversion&lt;/td&gt;
&lt;td&gt;54.1%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.0%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fp32 &lt;code&gt;gemvx&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;32.8%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;absent&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tensor Core&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.0%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total kernel time&lt;/td&gt;
&lt;td&gt;1,466.0 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;362.8 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Decode, gauge&lt;/td&gt;
&lt;td&gt;12.9 tok/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;48.4 tok/s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Peak HBM bandwidth&lt;/td&gt;
&lt;td&gt;298.083 GiB/s&lt;/td&gt;
&lt;td&gt;279.441 GiB/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Share of bandwidth roofline&lt;/td&gt;
&lt;td&gt;26%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~100%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;87% of decode on the g5g is not math.&lt;/strong&gt; It is dtype conversion plus an fp32&lt;br&gt;
&lt;code&gt;gemvx&lt;/code&gt; path. The instance runs at 26% of its own memory-bandwidth roofline; the&lt;br&gt;
g6 runs at roughly all of it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why This Is Invisible
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A wrong compute dtype does not raise. It emulates.&lt;/strong&gt; &lt;code&gt;bfloat16&lt;/code&gt; on a pre-Ampere&lt;br&gt;
GPU does not fail — XLA routes it through fp32 and decode quietly disappears into&lt;br&gt;
conversion.&lt;/p&gt;

&lt;p&gt;Turing has neither bf16 nor fp8. Its only real 16-bit datapath is &lt;code&gt;float16&lt;/code&gt;. So the&lt;br&gt;
port reads the live compute capability off the device rather than trusting a config&lt;br&gt;
file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;COMPUTE_DTYPE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;float16&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;IS_PRE_AMPERE&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;bfloat16&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server states its decision on the first line it emits, so a misconfigured&lt;br&gt;
instance is one &lt;code&gt;grep&lt;/code&gt; away rather than a mystery in the throughput:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;INFO ports.gemma4.jax_e_model: jax_e_model device policy: platform=gpu
compute_capability=8.9 compute_dtype=bfloat16 pallas_interpret=False
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Checkpoint Was Not the Problem
&lt;/h2&gt;

&lt;p&gt;The obvious hypothesis was bf16 weights on a chip with no bf16 datapath, so the&lt;br&gt;
checkpoint was converted to float16 host-side and re-run. Parameter dtypes read&lt;br&gt;
&lt;code&gt;{'float16': 541, 'uint8': 1, 'int8': 1}&lt;/code&gt; — and conversion &lt;strong&gt;stayed at 54.0%&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Storage dtype was never the problem. The fp32 &lt;code&gt;gemvx&lt;/code&gt; line is the tell: XLA was&lt;br&gt;
round-tripping through fp32 regardless of what the file on disk said. Only a card&lt;br&gt;
whose compute dtype matches its storage dtype removes it, which is what the g6&lt;br&gt;
shows.&lt;/p&gt;

&lt;p&gt;The measurement reproduces: the same profile on a different instance, a different&lt;br&gt;
AMI and a restored cache landed at 1466.0 ms against 1467.1 ms.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Health Check Will Not Catch This
&lt;/h2&gt;

&lt;p&gt;The g5g serves correctly the entire time. It returns HTTP &lt;code&gt;200&lt;/code&gt;, valid completions,&lt;br&gt;
and a healthy &lt;code&gt;/health&lt;/code&gt;. It is simply doing four times more work than it needs to.&lt;/p&gt;

&lt;p&gt;The related trap in this engine is a padding-eviction bug in the KV ring cache,&lt;br&gt;
whose failure mode is a token loop returning a clean &lt;code&gt;200&lt;/code&gt; with&lt;br&gt;
&lt;code&gt;status: "success"&lt;/code&gt; and output like &lt;code&gt;The The The The&lt;/code&gt;. Nothing in the logs or the&lt;br&gt;
metrics is red. Only a degeneracy check on the response body catches it, which the&lt;br&gt;
server now runs on every request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On this stack, HTTP 200 is not evidence of anything.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Teardown
&lt;/h2&gt;

&lt;p&gt;Both instances are spot and are terminated after collection. The XLA cache is&lt;br&gt;
pushed to S3 first, which is what makes the 6-second restore on the next fresh&lt;br&gt;
instance possible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The goal of this article was to compare two AWS GPU instance families for serving a&lt;br&gt;
small language model with a payload held byte-identical across both. The key to the&lt;br&gt;
solution was profiling decode rather than trusting throughput alone. The measured&lt;br&gt;
results were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;3.7x decode throughput on &lt;code&gt;g6.2xlarge&lt;/code&gt; over &lt;code&gt;g5g.2xlarge&lt;/code&gt;&lt;/strong&gt; — 48.4 against 12.9
tok/s — for the same code and the same weights.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;87% of decode on the g5g is dtype conversion and an fp32 path&lt;/strong&gt;, and 0.0% of it
on the g6.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The g5g runs at 26% of its memory-bandwidth roofline&lt;/strong&gt;; the g6 at roughly 100%.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing surfaces this.&lt;/strong&gt; The g5g serves valid completions with a healthy
endpoint throughout.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tensor Core utilization is 0.0% on both instances&lt;/strong&gt; and is not yet explained.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scope: two spot instances, one in &lt;code&gt;us-east-1d&lt;/code&gt;, each measured once with 3 repeats&lt;br&gt;
per sweep cell and medians reported. The two differ in host architecture (aarch64&lt;br&gt;
against x86_64) and base image as well as in GPU, so this is not a single-variable&lt;br&gt;
experiment; the payload is byte-identical across them — build &lt;code&gt;51bc52c9e2e9&lt;/code&gt;, the&lt;br&gt;
same config and the same 6,155,450,950 bytes of weights — which is the basis for&lt;br&gt;
attributing the difference to the chip. The g5g profile was reproduced on a second&lt;br&gt;
instance at 1466.0 ms against 1467.1 ms; the g6 profile was measured once. Price&lt;br&gt;
and price-per-token were not measured and are not claimed here.&lt;/p&gt;

&lt;p&gt;The strategy for using MCP for Gemma 4 serving across AWS GPU instance families was&lt;br&gt;
validated with an incremental step by step approach.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>jax</category>
      <category>cuda</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Gemma 4 in Pure JAX: What Changes Between Turing and Ada, and What Doesn't</title>
      <dc:creator>xbill</dc:creator>
      <pubDate>Mon, 31 Aug 2026 01:40:09 +0000</pubDate>
      <link>https://dev.to/gde/gemma-4-in-pure-jax-what-changes-between-turing-and-ada-and-what-doesnt-4c5e</link>
      <guid>https://dev.to/gde/gemma-4-in-pure-jax-what-changes-between-turing-and-ada-and-what-doesnt-4c5e</guid>
      <description>&lt;p&gt;This article is a measurement report on running a hand-written &lt;strong&gt;Gemma 4&lt;/strong&gt; port in&lt;br&gt;
&lt;strong&gt;pure JAX&lt;/strong&gt; across two NVIDIA GPUs a generation apart, and on the two places the&lt;br&gt;
"it's just JAX" abstraction leaks. One of those leaks costs 87% of decode and&lt;br&gt;
nothing in the logs is red.&lt;/p&gt;

&lt;p&gt;The code is here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/xbill9/gemma4-dev" rel="noopener noreferrer"&gt;https://github.com/xbill9/gemma4-dev&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  What This Article Measures
&lt;/h2&gt;

&lt;p&gt;One port, one build, one checkpoint, two cards. Everything below comes from two&lt;br&gt;
archived runs, named so you can check them.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;G5g&lt;/th&gt;
&lt;th&gt;G6&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Chip&lt;/td&gt;
&lt;td&gt;NVIDIA &lt;strong&gt;T4G&lt;/strong&gt; — Turing, SM 7.5, 15,360 MiB&lt;/td&gt;
&lt;td&gt;NVIDIA &lt;strong&gt;L4&lt;/strong&gt; — Ada, SM 8.9, 23,034 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Host&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;g5g.2xlarge&lt;/code&gt; spot — Graviton2, &lt;strong&gt;aarch64&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;g6.2xlarge&lt;/code&gt; spot — &lt;strong&gt;x86_64&lt;/strong&gt;, &lt;code&gt;us-east-1d&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Checkpoint&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;google/gemma-4-E2B-it&lt;/code&gt;, dense reference&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;google/gemma-4-E2B-it&lt;/code&gt;, dense reference&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compute dtype&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;float16&lt;/code&gt; (device-chosen)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;bfloat16&lt;/code&gt; (device-chosen)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stack&lt;/td&gt;
&lt;td&gt;jax 0.11.1, CUDA from pip&lt;/td&gt;
&lt;td&gt;jax 0.11.1, Python 3.14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run cited&lt;/td&gt;
&lt;td&gt;&lt;code&gt;2026-08-28-full-run-cached-g5g&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;2026-08-28-first-serve-g6&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Build id &lt;code&gt;51bc52c9e2e9&lt;/code&gt; on both, config &lt;code&gt;ple4 + int8_lm_head&lt;/code&gt;, and&lt;br&gt;
&lt;code&gt;tpu_jax_weight_bytes&lt;/code&gt; reads &lt;strong&gt;6,155,450,950&lt;/strong&gt; on both cards — the same integer.&lt;br&gt;
Only the chip and its host differ.&lt;/p&gt;
&lt;h2&gt;
  
  
  At This Point You Should Have
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An AWS account with spot capacity for &lt;code&gt;g5g.2xlarge&lt;/code&gt; and &lt;code&gt;g6.2xlarge&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A Hugging Face token with access to &lt;code&gt;google/gemma-4-E2B-it&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Python 3.13 or newer on the instance, system-wide, with no virtualenv&lt;/li&gt;
&lt;li&gt;No CUDA toolkit, no Rust toolchain, and no compiler — none of them are needed&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Why Pure JAX At All
&lt;/h2&gt;

&lt;p&gt;The port lives in &lt;code&gt;ports/gemma4/&lt;/code&gt; and is driven by a generation loop behind an&lt;br&gt;
OpenAI-compatible server. No PyTorch, no vLLM, no &lt;code&gt;torch_xla&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The premise under test is that the same source runs on both cards with nothing&lt;br&gt;
changed but a config file. It mostly holds. The interesting part is where it does&lt;br&gt;
not.&lt;/p&gt;
&lt;h2&gt;
  
  
  Gemma 4 E2B Is Not a Stock Transformer
&lt;/h2&gt;

&lt;p&gt;Any port has to carry four irregularities, and none of them are optional.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Two attention geometries.&lt;/strong&gt; Sliding layers use &lt;code&gt;head_dim=256&lt;/code&gt;, global layers
use &lt;strong&gt;512&lt;/strong&gt;. Most inference stacks assume one head dimension per model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;8:1 MQA&lt;/strong&gt;, so the KV budget is nothing like the parameter count suggests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A KV-share map&lt;/strong&gt; that collapses &lt;strong&gt;35 layers onto 15 caches&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A 512-slot sliding ring&lt;/strong&gt;, plus per-layer embeddings held in a &lt;strong&gt;4.70 GB&lt;/strong&gt;
table that is quantized to 4 bits on load.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  The Geometry Is What Breaks Other Stacks
&lt;/h2&gt;

&lt;p&gt;That first irregularity is the expensive one. On the vLLM path the heterogeneous&lt;br&gt;
head dims force the Triton attention backend:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Gemma4 model has heterogeneous head dimensions
(sliding=256, global=512); falling back to the Triton attention backend
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a Turing GPU that backend then asks for shared memory the hardware does not&lt;br&gt;
have:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;triton.runtime.errors.OutOfResources: out of resource: shared memory,
Required: 147456, Hardware limit: 65536
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;JAX never enters that conversation.&lt;/strong&gt; Attention is ordinary XLA rather than a&lt;br&gt;
hand-tiled kernel, so there is no per-block shared-memory ceiling in the attention&lt;br&gt;
path at all. The irregular geometry that is a special case everywhere else is just&lt;br&gt;
array shapes here.&lt;/p&gt;
&lt;h2&gt;
  
  
  Leak One: The dtype Policy Has To Read the Device
&lt;/h2&gt;

&lt;p&gt;This is the single most expensive lesson in the repository.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A wrong compute dtype does not raise. It emulates.&lt;/strong&gt; &lt;code&gt;bfloat16&lt;/code&gt; on a pre-Ampere&lt;br&gt;
GPU does not fail — XLA routes it through fp32 and most of decode disappears into&lt;br&gt;
conversion. Nothing in the logs is red.&lt;/p&gt;

&lt;p&gt;So the port does not take the dtype from a config file. It reads the live compute&lt;br&gt;
capability off the device:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;COMPUTE_DTYPE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;float16&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;IS_PRE_AMPERE&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;bfloat16&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the SM 8.9 Ada card that resolves to &lt;code&gt;bfloat16&lt;/code&gt;. On the SM 7.5 Turing card it&lt;br&gt;
resolves to &lt;code&gt;float16&lt;/code&gt; — Turing's only real 16-bit datapath, since it has neither&lt;br&gt;
bf16 nor fp8.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Process States What It Decided
&lt;/h2&gt;

&lt;p&gt;The first line the server emits is the policy, so a misconfiguration is one &lt;code&gt;grep&lt;/code&gt;&lt;br&gt;
away rather than a mystery in the throughput:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="err"&gt;INFO&lt;/span&gt; &lt;span class="py"&gt;ports.gemma4.jax_e_model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jax_e_model device policy: platform=gpu&lt;/span&gt;
&lt;span class="py"&gt;compute_capability&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;8.9 compute_dtype=bfloat16 pallas_interpret=False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pallas_interpret=False&lt;/code&gt; matters just as much. It is the difference between&lt;br&gt;
serving and silently running a simulator.&lt;/p&gt;
&lt;h2&gt;
  
  
  Leak Two: Pallas Is Not Portable as a Memory Model
&lt;/h2&gt;

&lt;p&gt;Here is the part that does not port, and it is not a bug. It is a real hardware&lt;br&gt;
difference wearing a portable API.&lt;/p&gt;

&lt;p&gt;The fused &lt;strong&gt;W4A16 kernel is written in Pallas&lt;/strong&gt;, and it was tiled for a device with&lt;br&gt;
16 MB of scratchpad per core. At this model's shapes the tiles want &lt;strong&gt;550 KiB to&lt;br&gt;
1.1 MiB per block&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;On a GPU, Pallas lowers through Triton, and those tiles become &lt;strong&gt;shared memory&lt;/strong&gt;.&lt;br&gt;
Turing gives you 64 KiB per block. Ada raises the ceiling, but nowhere near a&lt;br&gt;
megabyte.&lt;/p&gt;

&lt;p&gt;So the fast path runs on &lt;strong&gt;neither card&lt;/strong&gt;. The engine computes the requirement at&lt;br&gt;
startup and refuses with the arithmetic attached, rather than dying as a cryptic&lt;br&gt;
&lt;code&gt;OutOfResources&lt;/code&gt; at the first token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;check_w4a16_fits_scoped_memory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The practical consequence is that both GPU rigs serve the &lt;strong&gt;dense reference&lt;br&gt;
checkpoint&lt;/strong&gt; at 16-bit. &lt;strong&gt;Pallas is portable as an API and not portable as a&lt;br&gt;
memory model.&lt;/strong&gt; That boundary is worth knowing before planning a port around a&lt;br&gt;
fused kernel.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Bug That Returns 200 OK
&lt;/h2&gt;

&lt;p&gt;A padding-eviction bug in the KV ring cache cost a week, and it is the kind only&lt;br&gt;
Gemma 4's geometry produces.&lt;/p&gt;

&lt;p&gt;The invariant is that &lt;strong&gt;a cache index is an absolute real position, and padding&lt;br&gt;
never occupies an index a real position uses.&lt;/strong&gt; A port that right-pads into the&lt;br&gt;
512-slot ring violates it, and the failure mode is not a crash and not a NaN. It is&lt;br&gt;
a token loop — a clean HTTP &lt;code&gt;200&lt;/code&gt;, &lt;code&gt;status: "success"&lt;/code&gt;, and output like&lt;br&gt;
&lt;code&gt;The The The The&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Nothing in the logs is red. Nothing in the metrics is red. The only thing that&lt;br&gt;
catches it is a degeneracy check on the output itself, which the server now runs on&lt;br&gt;
every response.&lt;/p&gt;

&lt;p&gt;The scariest bugs in this project all returned success.&lt;/p&gt;
&lt;h2&gt;
  
  
  Install: No Build Step
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;jax[cuda13]&lt;/code&gt; supplies CUDA as wheels, so the install needs no CUDA toolkit, no&lt;br&gt;
Rust, and no compiler on the box.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Install: 117 s, with the cache restore included
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;XLA's persistent compilation cache ports as-is. On the T4G rig it restores &lt;strong&gt;805&lt;br&gt;
files / 12 MB in 6 seconds&lt;/strong&gt; onto a fresh instance, from a box that had already&lt;br&gt;
been terminated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Warm Up At the Shape You Measure
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;max_new_tokens&lt;/code&gt; is a &lt;code&gt;static_argnames&lt;/code&gt; entry, so &lt;code&gt;(bucket, max_tokens)&lt;/code&gt; is the&lt;br&gt;
compiled shape on every backend. A harness that does not warm up misreports the&lt;br&gt;
rig badly.&lt;/p&gt;

&lt;p&gt;On the T4G the first request off a fresh engine took &lt;strong&gt;18.06 s against 4.50 s&lt;br&gt;
warm&lt;/strong&gt; — a 4.0x whole-request ratio, measured in&lt;br&gt;
&lt;code&gt;2026-08-21-cuda13-py314-g5g&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That run also notes something worth repeating: the 56x figure from the first-serve&lt;br&gt;
baseline is &lt;strong&gt;TTFT specifically&lt;/strong&gt;, not the same measurement as the whole-request&lt;br&gt;
ratio. They are not interchangeable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Sweep
&lt;/h2&gt;

&lt;p&gt;64 output tokens, concurrency 1, 3 repeats per cell, median. "Decode, gauge" is the&lt;br&gt;
engine's steady-state counter. "End-to-end" is wall time over the whole request,&lt;br&gt;
prefill included.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Input tokens&lt;/th&gt;
&lt;th&gt;T4G gauge&lt;/th&gt;
&lt;th&gt;T4G end-to-end&lt;/th&gt;
&lt;th&gt;L4 gauge&lt;/th&gt;
&lt;th&gt;L4 end-to-end&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;41&lt;/td&gt;
&lt;td&gt;12.9 tok/s&lt;/td&gt;
&lt;td&gt;12.43 tok/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;48.5 tok/s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;46.23 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;521&lt;/td&gt;
&lt;td&gt;13.0 tok/s&lt;/td&gt;
&lt;td&gt;11.28 tok/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;48.4 tok/s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;42.87 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2,057&lt;/td&gt;
&lt;td&gt;12.9 tok/s&lt;/td&gt;
&lt;td&gt;8.22 tok/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;48.3 tok/s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;34.57 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3,593&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;48.3 tok/s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;27.55 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Decode Is Flat; End-To-End Is Not
&lt;/h2&gt;

&lt;p&gt;Decode moves 0.8% across a 50x context range on the T4G and 0.4% on the L4. End-to-end&lt;br&gt;
falls hard on both.&lt;/p&gt;

&lt;p&gt;That fall is prefill being linear in the padded bucket, not decode degrading. They&lt;br&gt;
are two different claims, and conflating them makes a benchmark a lie. &lt;strong&gt;Quote the&lt;br&gt;
gauge.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A cost proportional to the &lt;strong&gt;weights&lt;/strong&gt; rather than the context produces exactly this&lt;br&gt;
shape, which is why KV is not what sets decode speed on either card — despite&lt;br&gt;
Gemma 4's whole KV story.&lt;/p&gt;

&lt;p&gt;On context specifically: &lt;code&gt;MAX_MODEL_LEN=4096&lt;/code&gt; is the honest number on the T4G.&lt;br&gt;
4,105 prompt tokens serve; 5,120 fails on a prefill transient.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Profile: 87% of Decode Was Not Math
&lt;/h2&gt;

&lt;p&gt;Profiling decode with xprof on the Turing card, 20 decode steps with the service&lt;br&gt;
stopped:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;🥉 T4G (SM 7.5)&lt;/th&gt;
&lt;th&gt;🥇 L4 (SM 8.9)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;dtype conversion&lt;/td&gt;
&lt;td&gt;54.1%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.0%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fp32 &lt;code&gt;gemvx&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;32.8%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;absent&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tensor Core&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.0%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total kernel time&lt;/td&gt;
&lt;td&gt;1,466.0 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;362.8 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Decode, gauge&lt;/td&gt;
&lt;td&gt;12.9 tok/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;48.4 tok/s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Peak HBM bandwidth&lt;/td&gt;
&lt;td&gt;298.083 GiB/s&lt;/td&gt;
&lt;td&gt;279.441 GiB/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Share of bandwidth roofline&lt;/td&gt;
&lt;td&gt;26%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~100%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;1,466 ms of kernels across 108 distinct kernels on a Tensor Core GPU, without one&lt;br&gt;
Tensor Core firing. More than half of decode went to converting numbers between&lt;br&gt;
formats before any math happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Obvious Explanation Was Wrong
&lt;/h2&gt;

&lt;p&gt;The obvious hypothesis was bf16 weights being converted on a chip with no bf16&lt;br&gt;
datapath. So the checkpoint was converted to float16 host-side and re-run.&lt;br&gt;
Parameter dtypes read &lt;code&gt;{'float16': 541, 'uint8': 1, 'int8': 1}&lt;/code&gt; — and &lt;strong&gt;conversion&lt;br&gt;
stayed at 54.0%&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The measurement itself is solid. The same profile on a different instance, a&lt;br&gt;
different AMI and a restored cache landed at 1466.0 ms against 1467.1 ms. &lt;strong&gt;1.1 ms&lt;br&gt;
apart on 1467.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  It Was the Datapath, Not the Checkpoint
&lt;/h2&gt;

&lt;p&gt;The Ada card resolves it. Converting the stored weights changed nothing because&lt;br&gt;
storage dtype was never the problem: Turing has no native bf16, and the fp32&lt;br&gt;
&lt;code&gt;gemvx&lt;/code&gt; line is the tell — XLA was round-tripping through fp32 regardless of what&lt;br&gt;
the file on disk said.&lt;/p&gt;

&lt;p&gt;Give it a card where storage and compute dtype actually match, and the 54%&lt;br&gt;
conversion and the 32.8% fp32 path vanish &lt;strong&gt;together&lt;/strong&gt;. An 87% tax gone, for 3.7x&lt;br&gt;
the throughput, and a rig sitting at its bandwidth roofline instead of 26% of it.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;/health&lt;/code&gt; endpoint on the L4 reports &lt;code&gt;weights=bfloat16 activations=bfloat16&lt;br&gt;
kv_cache=bfloat16 pre_ampere=false&lt;/code&gt; — storage dtype and compute dtype matching for&lt;br&gt;
the first time on this engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Survived, and Is Still Unexplained
&lt;/h2&gt;

&lt;p&gt;Tensor Core utilization is &lt;strong&gt;0.0% on the Ada card too&lt;/strong&gt; — 100 distinct kernels,&lt;br&gt;
362.8 ms of them, and not one Tensor Core firing.&lt;/p&gt;

&lt;p&gt;Removing the dtype pressure made the machine roughly four times faster without&lt;br&gt;
making it touch the hardware it was sold for. That is the open question now, and it&lt;br&gt;
is a better one than the question this started with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Teardown
&lt;/h2&gt;

&lt;p&gt;Both rigs run on spot capacity and are terminated after collection. The XLA cache&lt;br&gt;
is pushed to S3 before teardown, which is what makes the 6-second restore on a&lt;br&gt;
fresh instance possible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The goal of this article was to find out which parts of "it's just JAX" survive a&lt;br&gt;
move between GPU generations. The key to the solution was reading the compute dtype&lt;br&gt;
off the live device rather than a config file. The measured results were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The model code ported untouched.&lt;/strong&gt; All four Gemma 4 irregularities, both
attention geometries, the KV-share map and the ring — identical source on both
cards.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The compilation cache and static-shape discipline ported untouched&lt;/strong&gt;, including
an 805-file restore in 6 seconds onto a fresh instance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The fused Pallas kernel ported to neither card&lt;/strong&gt;, because it was written
against a 16 MB scratchpad and GPU shared memory is 64 KiB per block on Turing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The dtype tax was 87% of decode on Turing and 0.0% on Ada&lt;/strong&gt;, worth 3.7x
throughput, with nothing red in any log.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tensor Core utilization is 0.0% on both cards&lt;/strong&gt; and remains unexplained.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scope: two spot instances, one in &lt;code&gt;us-east-1d&lt;/code&gt;, each measured once with 3 repeats&lt;br&gt;
per sweep cell and medians reported. The two boxes differ in host architecture&lt;br&gt;
(aarch64 against x86_64) and base image as well as in GPU, so this is not a&lt;br&gt;
single-variable experiment; the payload is byte-identical across them — same build&lt;br&gt;
&lt;code&gt;51bc52c9e2e9&lt;/code&gt;, same config, same 6,155,450,950 bytes of weights — which is the&lt;br&gt;
basis for attributing the difference to the chip. The Turing profile was reproduced&lt;br&gt;
on a second instance at 1466.0 ms against 1467.1 ms; the Ada profile was measured&lt;br&gt;
once.&lt;/p&gt;

&lt;p&gt;The strategy for using MCP for Gemma 4 serving across GPU generations was validated&lt;br&gt;
with an incremental step by step approach.&lt;/p&gt;

</description>
      <category>jax</category>
      <category>gemma</category>
      <category>cuda</category>
      <category>machinelearning</category>
    </item>
  </channel>
</rss>
