<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Junyi's Lab</title><link>https://www.junyi.dev/</link><description>Recent blog posts on Junyi's Lab</description><generator>Hugo (https://gohugo.io)</generator><language>en</language><managingEditor>junyi.h@comp.nus.edu.sg (Junyi Hou)</managingEditor><webMaster>junyi.h@comp.nus.edu.sg (Junyi Hou)</webMaster><lastBuildDate>Sat, 10 Oct 2026 14:00:00 +0800</lastBuildDate><atom:link href="https://www.junyi.dev/en/index.xml" rel="self" type="application/rss+xml"/><item><title>HLO Visualizer: Learn XLA HLO by Exploring It</title><link>https://www.junyi.dev/en/posts/hlo-visualizer/</link><pubDate>Sat, 10 Oct 2026 14:00:00 +0800</pubDate><author>junyi.h@comp.nus.edu.sg (Junyi Hou)</author><description>
When working with JAX on TPU, you eventually need to read HLO. You want to know what a computation became after compilation, why extra copies appeared, or what is inside a fusion. The answers start with a compiler-generated text file.
Instructions pack together shapes, layouts, tiling, memory spaces, and references to other computations.
I built HLO Visualizer to make that easier. It turns HLO text into an explorable graph, with explanations of the instruction you select, piece by piece.</description><content:encoded>&lt;p&gt;When working with JAX on TPU, you eventually need to read HLO. You want to know what a computation became after compilation, why extra copies appeared, or what is inside a fusion. The answers start with a compiler-generated text file.&lt;/p&gt;
&lt;p&gt;Instructions pack together shapes, layouts, tiling, memory spaces, and references to other computations.&lt;/p&gt;
&lt;p&gt;I built &lt;strong&gt;&lt;a href="https://www.junyi.dev/hlo-visualizer/" target="_blank" rel="noopener noreferrer"&gt;HLO Visualizer&lt;/a&gt;&lt;/strong&gt; to make that easier. It turns HLO text into an explorable graph, with explanations of the instruction you select, piece by piece.&lt;/p&gt;
&lt;h2 id="from-seeing-the-graph-to-understanding-an-instruction" &gt;
&lt;div&gt;
&lt;a href="#from-seeing-the-graph-to-understanding-an-instruction"&gt;
#
&lt;/a&gt;
From seeing the graph to understanding an instruction
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;There is already an official HLO visualization tool: &lt;strong&gt;XProf&amp;rsquo;s Graph Viewer&lt;/strong&gt;. It lets you explore an operation&amp;rsquo;s neighborhood, expand or collapse fusions, and connect the graph to profiling data. That is useful when you have identified an expensive operation and want to understand its dependencies. The &lt;a href="https://openxla.org/xprof/graph_viewer" target="_blank" rel="noopener noreferrer"&gt;official documentation&lt;/a&gt; describes this workflow.&lt;/p&gt;
&lt;figure&gt;
&lt;img src="https://www.junyi.dev/posts/hlo-visualizer/xprof-graph-viewer.png" alt="The official XProf Graph Viewer showing HLO instructions and dependencies around reduce.111" loading="lazy"&gt;
&lt;figcaption&gt;XProf Graph Viewer. Image from the &lt;a href="https://openxla.org/xprof/graph_viewer"&gt;OpenXLA documentation&lt;/a&gt;, used with attribution under &lt;a href="https://creativecommons.org/licenses/by/4.0/"&gt;CC BY 4.0&lt;/a&gt;.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;HLO Visualizer puts the graph and the explanation in one interface. Nodes distinguish inputs, computation, transfers, and control flow. Select one to trace dependencies, inspect its original HLO, and navigate to the computations it references.&lt;/p&gt;
&lt;figure&gt;
&lt;img src="https://www.junyi.dev/posts/hlo-visualizer/attention-graph.jpg" alt="HLO Visualizer showing compiled attention on TPU, with inputs, HBM-to-VMEM transfers, and fusion nodes" loading="lazy"&gt;
&lt;figcaption&gt;The attention example in HLO Visualizer. Follow inputs, transfers, and fusions, then click a node for details. This and the official screenshot use different programs; they illustrate the interfaces and reading workflows.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;XProf connects HLO to measured performance. HLO Visualizer starts with &lt;strong&gt;a piece of HLO text you want to understand&lt;/strong&gt;.&lt;/p&gt;
&lt;h2 id="put-the-tutorial-beside-the-instruction" &gt;
&lt;div&gt;
&lt;a href="#put-the-tutorial-beside-the-instruction"&gt;
#
&lt;/a&gt;
Put the tutorial beside the instruction
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;Google DeepMind&amp;rsquo;s &lt;em&gt;How to Scale Your Model&lt;/em&gt; has a useful introduction, &lt;a href="https://jax-ml.github.io/scaling-book/profiling/#how-to-read-an-xla-op" target="_blank" rel="noopener noreferrer"&gt;How to read an XLA op&lt;/a&gt;. It explains names, types, layouts, memory locations, and operands through a fusion example. OpenXLA&amp;rsquo;s &lt;a href="https://openxla.org/xla/operation_semantics" target="_blank" rel="noopener noreferrer"&gt;Operation semantics&lt;/a&gt; is the reference for precise operation behavior.&lt;/p&gt;
&lt;p&gt;The next step is applying those rules to your own program.&lt;/p&gt;
&lt;p&gt;Consider this simplified instruction:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#e2e4e5;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;%x = bf16[128,128]{1,0:T(8,128)(2,1)S(1)} parameter(0)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;It contains several layers of information:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;%x&lt;/code&gt; names the instruction&amp;rsquo;s result.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;bf16[128,128]&lt;/code&gt; gives the logical type and shape.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1,0&lt;/code&gt; is the minor-to-major dimension order.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;T(8,128)(2,1)&lt;/code&gt; describes two levels of physical tiling.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;S(1)&lt;/code&gt; means VMEM in these TPU examples.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;parameter(0)&lt;/code&gt; declares input 0 of the current computation.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The inspector breaks the original instruction into colored segments and explains its name, operation, operands, and attributes. Expand the type to inspect shape, dimension order, tiling, and memory space separately. Tuple elements can be explored individually too.&lt;/p&gt;
&lt;figure&gt;
&lt;img src="https://www.junyi.dev/posts/hlo-visualizer/decomposition.png" alt="The inspector breaking a while instruction into result type, name, operation, inputs, loop condition, and loop body" loading="lazy" style="max-width: 480px; width: 100%; display: block; margin: auto;"&gt;
&lt;figcaption&gt;A while instruction, broken down into explanations of its state, condition, and body.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2 id="draw-the-tpu-layout" &gt;
&lt;div&gt;
&lt;a href="#draw-the-tpu-layout"&gt;
#
&lt;/a&gt;
Draw the TPU layout
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;Layout is an important part of HLO. &lt;strong&gt;Logical shape and physical storage layout describe different things.&lt;/strong&gt; A &lt;code&gt;128 × 128&lt;/code&gt; array can be split into tiles, with another level of tiling inside each tile. OpenXLA&amp;rsquo;s &lt;a href="https://openxla.org/xla/shapes" target="_blank" rel="noopener noreferrer"&gt;Shapes and layout&lt;/a&gt; and &lt;a href="https://openxla.org/xla/tiled_layout" target="_blank" rel="noopener noreferrer"&gt;Tiled layout&lt;/a&gt; document the representation.&lt;/p&gt;
&lt;p&gt;For the common TPU layout &lt;code&gt;T(8,128)(2,1)&lt;/code&gt;, HLO Visualizer draws a corner of a tile, labeling physical offsets and inner tile boundaries. The two sets of numbers become something you can see.&lt;/p&gt;
&lt;figure&gt;
&lt;img src="https://www.junyi.dev/posts/hlo-visualizer/array-layout.png" alt="A diagram of bf16 T(8,128)(2,1) tiling, with physical offsets and red outlines around 2-by-1 inner tiles" loading="lazy" style="max-width: 480px; width: 100%; display: block; margin: auto;"&gt;
&lt;figcaption&gt;The top-left corner of an 8×128 outer tile. Red outlines mark 2×1 inner tiles; numbers are physical offsets within the tile.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;The tool also explains TPU memory locations such as HBM, VMEM, and SMEM, helping you interpret transfer instructions. Memory-space numbers depend on the backend; the explanations here use the project&amp;rsquo;s TPU v6e examples as their context.&lt;/p&gt;
&lt;h2 id="start-with-a-small-program" &gt;
&lt;div&gt;
&lt;a href="#start-with-a-small-program"&gt;
#
&lt;/a&gt;
Start with a small program
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;The bundled examples come from &lt;strong&gt;21 small JAX programs compiled on TPU v6e&lt;/strong&gt;, each with HLO before and after optimization. Try &lt;a href="https://www.junyi.dev/hlo-visualizer/?example=attention.after" target="_blank" rel="noopener noreferrer"&gt;attention&lt;/a&gt; for inputs and fusions, &lt;a href="https://www.junyi.dev/hlo-visualizer/?example=fori_matmul.after" target="_blank" rel="noopener noreferrer"&gt;fori_matmul&lt;/a&gt; for loops, or &lt;a href="https://www.junyi.dev/hlo-visualizer/?example=gather_scatter.after" target="_blank" rel="noopener noreferrer"&gt;gather_scatter&lt;/a&gt; for indexing operations.&lt;/p&gt;
&lt;p&gt;Open an example, enter its entry computation from the sidebar, and select a node. Inspect its inputs and result type, then explore a computation referenced by a fusion or while. Switch between Before and After in Examples to inspect how compilation changes the graph.&lt;/p&gt;
&lt;p&gt;To explore your own code, export HLO through &lt;a href="https://docs.jax.dev/en/latest/aot.html" target="_blank" rel="noopener noreferrer"&gt;JAX&amp;rsquo;s lowering and compilation interfaces&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#e2e4e5;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff6ac1"&gt;import&lt;/span&gt; jax
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff6ac1"&gt;import&lt;/span&gt; jax.numpy &lt;span style="color:#ff6ac1"&gt;as&lt;/span&gt; jnp
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff6ac1"&gt;def&lt;/span&gt; &lt;span style="color:#57c7ff"&gt;f&lt;/span&gt;(x, w):
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff6ac1"&gt;return&lt;/span&gt; jax&lt;span style="color:#ff6ac1"&gt;.&lt;/span&gt;nn&lt;span style="color:#ff6ac1"&gt;.&lt;/span&gt;relu(x &lt;span style="color:#ff6ac1"&gt;@&lt;/span&gt; w)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;x &lt;span style="color:#ff6ac1"&gt;=&lt;/span&gt; jnp&lt;span style="color:#ff6ac1"&gt;.&lt;/span&gt;ones((&lt;span style="color:#ff9f43"&gt;128&lt;/span&gt;, &lt;span style="color:#ff9f43"&gt;128&lt;/span&gt;), dtype&lt;span style="color:#ff6ac1"&gt;=&lt;/span&gt;jnp&lt;span style="color:#ff6ac1"&gt;.&lt;/span&gt;bfloat16)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;w &lt;span style="color:#ff6ac1"&gt;=&lt;/span&gt; jnp&lt;span style="color:#ff6ac1"&gt;.&lt;/span&gt;ones((&lt;span style="color:#ff9f43"&gt;128&lt;/span&gt;, &lt;span style="color:#ff9f43"&gt;128&lt;/span&gt;), dtype&lt;span style="color:#ff6ac1"&gt;=&lt;/span&gt;jnp&lt;span style="color:#ff6ac1"&gt;.&lt;/span&gt;bfloat16)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;lowered &lt;span style="color:#ff6ac1"&gt;=&lt;/span&gt; jax&lt;span style="color:#ff6ac1"&gt;.&lt;/span&gt;jit(f)&lt;span style="color:#ff6ac1"&gt;.&lt;/span&gt;lower(x, w)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#78787e"&gt;# HLO before optimization&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff5c57"&gt;print&lt;/span&gt;(lowered&lt;span style="color:#ff6ac1"&gt;.&lt;/span&gt;as_text(dialect&lt;span style="color:#ff6ac1"&gt;=&lt;/span&gt;&lt;span style="color:#5af78e"&gt;&amp;#34;hlo&amp;#34;&lt;/span&gt;))
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#78787e"&gt;# Compiled HLO; layouts and memory spaces depend on the backend&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff5c57"&gt;print&lt;/span&gt;(lowered&lt;span style="color:#ff6ac1"&gt;.&lt;/span&gt;compile()&lt;span style="color:#ff6ac1"&gt;.&lt;/span&gt;as_text())
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Copy either output and paste it through &lt;strong&gt;Open HLO&lt;/strong&gt;. HLO text from XLA&amp;rsquo;s &lt;code&gt;--xla_dump_to&lt;/code&gt; works too.&lt;/p&gt;
&lt;p&gt;I want someone learning HLO for the first time to be able to connect the syntax to a small, real program. &lt;strong&gt;Open the graph, select an instruction, and follow the data.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.junyi.dev/hlo-visualizer/" target="_blank" rel="noopener noreferrer"&gt;Try HLO Visualizer&lt;/a&gt;&lt;/strong&gt;. If it helps you understand your HLO, a star on &lt;a href="https://github.com/Junyi-99/hlo-visualizer" target="_blank" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; is welcome. Please open an issue for instructions that are unclear or do not parse correctly.&lt;/p&gt;</content:encoded><category>TPU</category><category>JAX</category><category>XLA</category><category>HLO</category><category>Tech</category><guid isPermaLink="true">https://www.junyi.dev/en/posts/hlo-visualizer/</guid></item><item><title>LLM Inference on TPU v6e-4: Small Dense, Large MoE, and Large Dense Models</title><link>https://www.junyi.dev/en/posts/tpu-v6e/</link><pubDate>Thu, 11 Jun 2026 00:00:00 Z</pubDate><author>junyi.h@comp.nus.edu.sg (Junyi Hou)</author><description>
# Abstract We benchmark LLM inference on one Google TPU v6e-4 host (four chips, one VM) with four chips in one VM. We use vLLM 0.20.0 with the tpu-inference backend and an fp8 KV cache.
We test three Qwen3 models:
Model Type Params Parallelism Chips Qwen3.5-4B dense 4B active tp1 1 Qwen3-30B-A3B MoE 30B total / 3B active tp4 4 Qwen3-32B dense 32B active tp4 4 We measure three parts of inference: prefill, decode, and end-to-end online serving.</description><content:encoded>&lt;h2 id="abstract" &gt;
&lt;div&gt;
&lt;a href="#abstract"&gt;
#
&lt;/a&gt;
Abstract
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;We benchmark LLM inference on one Google &lt;code&gt;TPU v6e-4&lt;/code&gt; host (four chips, one VM) with four chips in one VM. We use &lt;code&gt;vLLM 0.20.0&lt;/code&gt; with the &lt;code&gt;tpu-inference&lt;/code&gt; backend and an &lt;code&gt;fp8&lt;/code&gt; KV cache.&lt;/p&gt;
&lt;p&gt;We test three Qwen3 models:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th style="text-align: right"&gt;Params&lt;/th&gt;
&lt;th style="text-align: right"&gt;Parallelism&lt;/th&gt;
&lt;th style="text-align: right"&gt;Chips&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3.5-4B&lt;/td&gt;
&lt;td&gt;dense&lt;/td&gt;
&lt;td style="text-align: right"&gt;4B active&lt;/td&gt;
&lt;td style="text-align: right"&gt;tp1&lt;/td&gt;
&lt;td style="text-align: right"&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-30B-A3B&lt;/td&gt;
&lt;td&gt;MoE&lt;/td&gt;
&lt;td style="text-align: right"&gt;30B total / 3B active&lt;/td&gt;
&lt;td style="text-align: right"&gt;tp4&lt;/td&gt;
&lt;td style="text-align: right"&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-32B&lt;/td&gt;
&lt;td&gt;dense&lt;/td&gt;
&lt;td style="text-align: right"&gt;32B active&lt;/td&gt;
&lt;td style="text-align: right"&gt;tp4&lt;/td&gt;
&lt;td style="text-align: right"&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;We measure three parts of inference: prefill, decode, and end-to-end online serving.&lt;/p&gt;
&lt;p&gt;The MoE model is fastest in all three tests. It reaches 26,063 tok/s in prefill, compared with 19,714 tok/s for the dense 32B model. At batch size 1, its decode latency is 7.0 ms per token, compared with 17.9 ms for the dense 32B model. In online serving, it reaches 1.27 req/s and 1,303 output tok/s, compared with 0.88 req/s and 901 output tok/s for the dense 32B model.&lt;/p&gt;
&lt;p&gt;The 4B model has good latency at low load, but it does not handle high concurrency well on one chip. Its stable serving capacity is about 0.45 req/s.&lt;/p&gt;
&lt;p&gt;This is not a TPU-versus-GPU comparison. We did not run a GPU baseline. The goal is to report clear TPU v6e numbers using the same vLLM tools that users would run in practice.&lt;/p&gt;
&lt;h2 id="1-motivation" &gt;
&lt;div&gt;
&lt;a href="#1-motivation"&gt;
#
&lt;/a&gt;
1. Motivation
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;Public TPU data for LLM inference is limited. The only TPU submission Google has made to MLPerf Inference is SDXL image generation, not an LLM. The TPU LLM numbers is measured on Google&amp;rsquo;s JAX/JetStream stack rather than the vLLM path most practitioners deploy.&lt;/p&gt;
&lt;p&gt;We measured the performance ourselves on one TPU v6e-4 host. The goal is to give simple, reproducible numbers for three common model types:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;a small dense model,&lt;/li&gt;
&lt;li&gt;a large MoE model,&lt;/li&gt;
&lt;li&gt;a large dense model.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;All comparisons are between these three models on the same TPU host. We do not compare against GPUs, prices, or total cost.&lt;/p&gt;
&lt;h2 id="2-experimental-setup" &gt;
&lt;div&gt;
&lt;a href="#2-experimental-setup"&gt;
#
&lt;/a&gt;
2. Experimental Setup
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;All measurements use one TPU v6e-4 host with four chips in one VM. The 4B model uses one chip. The 30B-A3B and 32B models use all four chips. For per-chip numbers, we divide the four-chip throughput by four.&lt;/p&gt;
&lt;p&gt;We use &lt;code&gt;vLLM 0.20.0&lt;/code&gt;, &lt;code&gt;JAX 0.10.0&lt;/code&gt;, &lt;code&gt;libtpu 0.0.40&lt;/code&gt;, &lt;code&gt;Python 3.11&lt;/code&gt; with the &lt;code&gt;tpu-inference&lt;/code&gt; backend. The KV cache uses &lt;code&gt;fp8_e5m2&lt;/code&gt; in all runs. We report exact versions because TPU performance can change quickly across releases.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th style="text-align: right"&gt;Total / active params&lt;/th&gt;
&lt;th style="text-align: right"&gt;Parallelism&lt;/th&gt;
&lt;th style="text-align: right"&gt;Chips&lt;/th&gt;
&lt;th&gt;Config&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3.5-4B&lt;/td&gt;
&lt;td&gt;dense&lt;/td&gt;
&lt;td style="text-align: right"&gt;4B / 4B&lt;/td&gt;
&lt;td style="text-align: right"&gt;tp1&lt;/td&gt;
&lt;td style="text-align: right"&gt;1&lt;/td&gt;
&lt;td&gt;GDN, MBT2048&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-30B-A3B&lt;/td&gt;
&lt;td&gt;MoE&lt;/td&gt;
&lt;td style="text-align: right"&gt;30B / 3B&lt;/td&gt;
&lt;td style="text-align: right"&gt;tp4&lt;/td&gt;
&lt;td style="text-align: right"&gt;4&lt;/td&gt;
&lt;td&gt;GMU0.8, MBT8192&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-32B&lt;/td&gt;
&lt;td&gt;dense&lt;/td&gt;
&lt;td style="text-align: right"&gt;32B / 32B&lt;/td&gt;
&lt;td style="text-align: right"&gt;tp4&lt;/td&gt;
&lt;td style="text-align: right"&gt;4&lt;/td&gt;
&lt;td&gt;GMU0.8, MBT8192&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;figcaption&gt;Table 1. Model configurations used in all experiments. GDN = guided decoding off, GMU = GPU memory utilization, MBT = max-num-batched-tokens.&lt;/figcaption&gt;
&lt;p&gt;The key comparison is between Qwen3-30B-A3B and Qwen3-32B. They have similar total size, but the MoE model only uses 3B parameters per token, while the dense 32B model uses all 32B.&lt;/p&gt;
&lt;p&gt;We run three tests.&lt;/p&gt;
&lt;p&gt;For prefill, we use concurrency 1 and output length 1. We vary context length over 512, 1024, 2048, 4096, and 8192 tokens. This measures how fast the model processes the input prompt.&lt;/p&gt;
&lt;p&gt;For decode, we fix the output length at 128 tokens and vary batch size over 1, 4, 16, and 64. We mainly report context length 1024, with context length 4096 as a longer-KV case. This measures the cost of generating tokens after prefill.&lt;/p&gt;
&lt;p&gt;For online serving, we use &lt;code&gt;vllm bench serve&lt;/code&gt; against the OpenAI-compatible endpoint. Input and output lengths are both 1024 tokens. We increase the request rate until the server saturates.&lt;/p&gt;
&lt;p&gt;Metrics:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;TTFT: time to first token. This mainly measures prefill.&lt;/li&gt;
&lt;li&gt;TPOT: time per output token. This mainly measures decode.&lt;/li&gt;
&lt;li&gt;E2E latency: full request time.&lt;/li&gt;
&lt;li&gt;Prefill throughput: context length divided by p50 TTFT.&lt;/li&gt;
&lt;li&gt;Decode throughput: batch size × 1000 divided by p50 TPOT.&lt;/li&gt;
&lt;li&gt;Output throughput: generated tokens per second.&lt;/li&gt;
&lt;li&gt;Request throughput: completed requests per second.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We report p50 unless stated otherwise.&lt;/p&gt;
&lt;h2 id="3-results" &gt;
&lt;div&gt;
&lt;a href="#3-results"&gt;
#
&lt;/a&gt;
3. Results
&lt;/div&gt;
&lt;/h2&gt;
&lt;h3 id="31-prefill" &gt;
&lt;div&gt;
&lt;a href="#31-prefill"&gt;
##
&lt;/a&gt;
3.1 Prefill
&lt;/div&gt;
&lt;/h3&gt;
&lt;figure&gt;
&lt;img style="border-radius: 8px" src="https://www.junyi.dev/posts/tpu-v6e/figures/fig1_prefill.png" alt="Prefill throughput versus context length on TPU v6e-4"&gt;
&lt;figcaption&gt;Figure 1. Prefill throughput versus context length. Higher is better. Throughput is aggregate over the chips each model uses — &lt;b&gt;the 30B-A3B and 32B use four chips&lt;/b&gt;, the 4B uses one.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;The MoE model has the highest prefill throughput.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th style="text-align: right"&gt;Best prefill throughput&lt;/th&gt;
&lt;th style="text-align: right"&gt;Per-chip peak&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-30B-A3B&lt;/td&gt;
&lt;td style="text-align: right"&gt;26,063 tok/s&lt;/td&gt;
&lt;td style="text-align: right"&gt;~6.5k tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-32B&lt;/td&gt;
&lt;td style="text-align: right"&gt;19,714 tok/s&lt;/td&gt;
&lt;td style="text-align: right"&gt;~4.9k tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3.5-4B&lt;/td&gt;
&lt;td style="text-align: right"&gt;4,177 tok/s&lt;/td&gt;
&lt;td style="text-align: right"&gt;~4.2k tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;figcaption&gt;Table 2. Peak prefill throughput and per-chip peak for each model.&lt;/figcaption&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th style="text-align: right"&gt;ctx 512&lt;/th&gt;
&lt;th style="text-align: right"&gt;ctx 1024&lt;/th&gt;
&lt;th style="text-align: right"&gt;ctx 2048&lt;/th&gt;
&lt;th style="text-align: right"&gt;ctx 4096&lt;/th&gt;
&lt;th style="text-align: right"&gt;ctx 8192&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-30B-A3B&lt;/td&gt;
&lt;td style="text-align: right"&gt;35 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;51 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;84 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;157 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;318 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-32B&lt;/td&gt;
&lt;td style="text-align: right"&gt;38 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;58 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;104 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;208 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;461 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3.5-4B&lt;/td&gt;
&lt;td style="text-align: right"&gt;432 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;452 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;495 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;983 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;1,961 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;figcaption&gt;Table 3. p50 TTFT at each context length (ms).&lt;/figcaption&gt;
&lt;p&gt;The 4B result is limited by its single-chip setup and by &lt;code&gt;max-num-batched-tokens=2048&lt;/code&gt;. For contexts above 2048, prefill is split into chunks, so the curve flattens.&lt;/p&gt;
&lt;h3 id="32-decode" &gt;
&lt;div&gt;
&lt;a href="#32-decode"&gt;
##
&lt;/a&gt;
3.2 Decode
&lt;/div&gt;
&lt;/h3&gt;
&lt;figure&gt;
&lt;img style="border-radius: 8px" src="https://www.junyi.dev/posts/tpu-v6e/figures/fig2_decode_tpot.png" alt="Decode p50 TPOT versus batch size at context 1024 and 4096"&gt;
&lt;figcaption&gt;Figure 2. Decode p50 TPOT versus batch size. Solid lines: context 1024. Dashed lines: context 4096. Lower is better.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure&gt;
&lt;img style="border-radius: 8px" src="https://www.junyi.dev/posts/tpu-v6e/figures/fig3_decode_throughput.png" alt="Aggregate decode throughput versus batch size at context 1024 and 4096"&gt;
&lt;figcaption&gt;Figure 3. Aggregate decode throughput versus batch size. Solid lines: context 1024. Dashed lines: context 4096. Higher is better. Throughput is aggregate over the chips each model uses — the 30B-A3B and 32B use four chips, the 4B uses one.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th style="text-align: right"&gt;BS&lt;/th&gt;
&lt;th style="text-align: right"&gt;p50 TPOT&lt;/th&gt;
&lt;th style="text-align: right"&gt;p99 TPOT&lt;/th&gt;
&lt;th style="text-align: right"&gt;throughput&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-30B-A3B&lt;/td&gt;
&lt;td style="text-align: right"&gt;1&lt;/td&gt;
&lt;td style="text-align: right"&gt;7.0 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;7.1 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;143 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-30B-A3B&lt;/td&gt;
&lt;td style="text-align: right"&gt;4&lt;/td&gt;
&lt;td style="text-align: right"&gt;8.8 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;9.9 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;456 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-30B-A3B&lt;/td&gt;
&lt;td style="text-align: right"&gt;16&lt;/td&gt;
&lt;td style="text-align: right"&gt;14.9 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;17.5 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;1,071 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-30B-A3B&lt;/td&gt;
&lt;td style="text-align: right"&gt;64&lt;/td&gt;
&lt;td style="text-align: right"&gt;48.1 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;152.7 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;1,331 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-32B&lt;/td&gt;
&lt;td style="text-align: right"&gt;1&lt;/td&gt;
&lt;td style="text-align: right"&gt;17.9 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;17.9 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;56 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-32B&lt;/td&gt;
&lt;td style="text-align: right"&gt;4&lt;/td&gt;
&lt;td style="text-align: right"&gt;18.2 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;19.4 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;220 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-32B&lt;/td&gt;
&lt;td style="text-align: right"&gt;16&lt;/td&gt;
&lt;td style="text-align: right"&gt;22.4 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;27.4 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;715 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-32B&lt;/td&gt;
&lt;td style="text-align: right"&gt;64&lt;/td&gt;
&lt;td style="text-align: right"&gt;58.4 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;65.3 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;1,096 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3.5-4B&lt;/td&gt;
&lt;td style="text-align: right"&gt;1&lt;/td&gt;
&lt;td style="text-align: right"&gt;10.7 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;10.7 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;93 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3.5-4B&lt;/td&gt;
&lt;td style="text-align: right"&gt;4&lt;/td&gt;
&lt;td style="text-align: right"&gt;16.2 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;18.3 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;247 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3.5-4B&lt;/td&gt;
&lt;td style="text-align: right"&gt;16&lt;/td&gt;
&lt;td style="text-align: right"&gt;41.4 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;647.3 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;387 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3.5-4B&lt;/td&gt;
&lt;td style="text-align: right"&gt;64&lt;/td&gt;
&lt;td style="text-align: right"&gt;80.5 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;97.2 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;795 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;figcaption&gt;Table 4. Decode TPOT at context length 1024 (p50, p99, and aggregate throughput).&lt;/figcaption&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th style="text-align: right"&gt;BS&lt;/th&gt;
&lt;th style="text-align: right"&gt;p50 TPOT&lt;/th&gt;
&lt;th style="text-align: right"&gt;p99 TPOT&lt;/th&gt;
&lt;th style="text-align: right"&gt;throughput&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-30B-A3B&lt;/td&gt;
&lt;td style="text-align: right"&gt;1&lt;/td&gt;
&lt;td style="text-align: right"&gt;7.0 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;7.1 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;143 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-30B-A3B&lt;/td&gt;
&lt;td style="text-align: right"&gt;4&lt;/td&gt;
&lt;td style="text-align: right"&gt;9.7 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;12.9 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;411 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-30B-A3B&lt;/td&gt;
&lt;td style="text-align: right"&gt;16&lt;/td&gt;
&lt;td style="text-align: right"&gt;22.3 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;28.5 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;719 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-30B-A3B&lt;/td&gt;
&lt;td style="text-align: right"&gt;64&lt;/td&gt;
&lt;td style="text-align: right"&gt;96.8 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;213.9 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;661 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-32B&lt;/td&gt;
&lt;td style="text-align: right"&gt;1&lt;/td&gt;
&lt;td style="text-align: right"&gt;19.1 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;19.3 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;53 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-32B&lt;/td&gt;
&lt;td style="text-align: right"&gt;4&lt;/td&gt;
&lt;td style="text-align: right"&gt;21.4 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;25.2 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;187 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-32B&lt;/td&gt;
&lt;td style="text-align: right"&gt;16&lt;/td&gt;
&lt;td style="text-align: right"&gt;37.7 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;130.6 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;424 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-32B&lt;/td&gt;
&lt;td style="text-align: right"&gt;64&lt;/td&gt;
&lt;td style="text-align: right"&gt;82.5 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;92.8 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;775 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3.5-4B&lt;/td&gt;
&lt;td style="text-align: right"&gt;1&lt;/td&gt;
&lt;td style="text-align: right"&gt;10.8 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;10.8 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;93 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3.5-4B&lt;/td&gt;
&lt;td style="text-align: right"&gt;4&lt;/td&gt;
&lt;td style="text-align: right"&gt;25.3 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;34.1 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;158 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3.5-4B&lt;/td&gt;
&lt;td style="text-align: right"&gt;16&lt;/td&gt;
&lt;td style="text-align: right"&gt;85.0 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;89.2 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;188 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3.5-4B&lt;/td&gt;
&lt;td style="text-align: right"&gt;64&lt;/td&gt;
&lt;td style="text-align: right"&gt;85.0 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;89.2 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;753 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;figcaption&gt;Table 5. Decode TPOT at context length 4096 (p50, p99, and aggregate throughput).&lt;/figcaption&gt;
&lt;p&gt;At batch size 1, the MoE model is 2.5× faster than the dense 32B model: 7.0 ms versus 17.9 ms at context 1024. This follows active parameter count. Decode is memory-bandwidth-bound. The MoE model reads 3B active parameters per token; the dense 32B model reads all 32B.&lt;/p&gt;
&lt;p&gt;Tail latency — the p99 columns of Tables 4 and 5 — stays tight for the four-chip models until BS64 (the 30B-A3B reaches p99 152.7 ms against a 48.1 ms p50 at ctx 1024). The 4B model on one chip is the outlier: at BS16 ctx 1024 its p99 TPOT reaches 647.3 ms against a 41.4 ms p50. These decode runs are driven at an unlimited request rate, so on the single chip the prefill chunks of newly admitted requests repeatedly preempt in-flight decode — the median inter-token latency stays near 12 ms while a heavy minority of requests stall. This marks the 4B&amp;rsquo;s single-chip concurrency limit, not steady-state decode.&lt;/p&gt;
&lt;p&gt;At context 4096, longer KV sequences reduce throughput across all models. The 30B-A3B at BS64 drops from 1,331 tok/s (ctx 1024) to 661 tok/s (ctx 4096). The dense 32B drops less in relative terms (1,096 to 775 tok/s) because its compute is already the bottleneck at ctx 1024. The 4B model saturates at BS16 for ctx 4096: p50 TPOT at BS64 equals BS16 (85.0 ms both), meaning additional concurrency gives no throughput gain on one chip.&lt;/p&gt;
&lt;h3 id="33-online-serving" &gt;
&lt;div&gt;
&lt;a href="#33-online-serving"&gt;
##
&lt;/a&gt;
3.3 Online serving
&lt;/div&gt;
&lt;/h3&gt;
&lt;figure&gt;
&lt;img style="border-radius: 8px" src="https://www.junyi.dev/posts/tpu-v6e/figures/fig4_e2e_pareto.png" alt="Online-serving throughput-latency curve"&gt;
&lt;figcaption&gt;Figure 4. Online serving throughput and latency. The x-axis is output throughput. The y-axis is p50 TPOT. Lower-right is better. Throughput is aggregate over the chips each model uses — the 30B-A3B and 32B use four chips, the 4B uses one.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;In online serving, higher request rate gives higher throughput, but also higher latency. Each model reaches a point where adding more requests no longer helps.&lt;/p&gt;
&lt;figure&gt;
&lt;img style="border-radius: 8px" src="https://www.junyi.dev/posts/tpu-v6e/figures/fig7_e2e_sweep.png" alt="End-to-end TTFT and TPOT versus output throughput"&gt;
&lt;figcaption&gt;Figure 5. End-to-end latency sweep. Left: p50 TTFT versus output throughput. Right: p50 TPOT versus output throughput. The × marker on the 4B line marks the concurrency-collapse point under unlimited load. Throughput is aggregate over the chips each model uses — the 30B-A3B and 32B use four chips, the 4B uses one.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="text-align: right"&gt;Req rate&lt;/th&gt;
&lt;th style="text-align: right"&gt;Actual req/s&lt;/th&gt;
&lt;th style="text-align: right"&gt;Output tok/s&lt;/th&gt;
&lt;th style="text-align: right"&gt;TTFT p50&lt;/th&gt;
&lt;th style="text-align: right"&gt;TTFT p99&lt;/th&gt;
&lt;th style="text-align: right"&gt;TPOT p50&lt;/th&gt;
&lt;th style="text-align: right"&gt;TPOT p99&lt;/th&gt;
&lt;th style="text-align: right"&gt;E2E p50&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="text-align: right"&gt;0.2&lt;/td&gt;
&lt;td style="text-align: right"&gt;0.19&lt;/td&gt;
&lt;td style="text-align: right"&gt;197&lt;/td&gt;
&lt;td style="text-align: right"&gt;90 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;2,682 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;7.9 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;9.0 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;8.2 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: right"&gt;0.4&lt;/td&gt;
&lt;td style="text-align: right"&gt;0.36&lt;/td&gt;
&lt;td style="text-align: right"&gt;366&lt;/td&gt;
&lt;td style="text-align: right"&gt;92 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;190 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;9.2 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;10.1 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;9.5 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: right"&gt;0.6&lt;/td&gt;
&lt;td style="text-align: right"&gt;0.52&lt;/td&gt;
&lt;td style="text-align: right"&gt;537&lt;/td&gt;
&lt;td style="text-align: right"&gt;94 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;1,331 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;11.2 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;13.8 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;11.6 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: right"&gt;0.8&lt;/td&gt;
&lt;td style="text-align: right"&gt;0.62&lt;/td&gt;
&lt;td style="text-align: right"&gt;635&lt;/td&gt;
&lt;td style="text-align: right"&gt;116 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;6,335 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;18.8 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;28.7 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;24.6 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: right"&gt;∞&lt;/td&gt;
&lt;td style="text-align: right"&gt;1.27&lt;/td&gt;
&lt;td style="text-align: right"&gt;1,303&lt;/td&gt;
&lt;td style="text-align: right"&gt;712 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;1,092 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;22.3 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;22.8 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;23.6 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;figcaption&gt;Table 6. Qwen3-30B-A3B online serving results (ISL = OSL = 1024 tokens).&lt;/figcaption&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="text-align: right"&gt;Req rate&lt;/th&gt;
&lt;th style="text-align: right"&gt;Actual req/s&lt;/th&gt;
&lt;th style="text-align: right"&gt;Output tok/s&lt;/th&gt;
&lt;th style="text-align: right"&gt;TTFT p50&lt;/th&gt;
&lt;th style="text-align: right"&gt;TTFT p99&lt;/th&gt;
&lt;th style="text-align: right"&gt;TPOT p50&lt;/th&gt;
&lt;th style="text-align: right"&gt;TPOT p99&lt;/th&gt;
&lt;th style="text-align: right"&gt;E2E p50&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="text-align: right"&gt;0.15&lt;/td&gt;
&lt;td style="text-align: right"&gt;0.14&lt;/td&gt;
&lt;td style="text-align: right"&gt;144&lt;/td&gt;
&lt;td style="text-align: right"&gt;115 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;2,243 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;18.0 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;18.3 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;18.5 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: right"&gt;0.3&lt;/td&gt;
&lt;td style="text-align: right"&gt;0.26&lt;/td&gt;
&lt;td style="text-align: right"&gt;271&lt;/td&gt;
&lt;td style="text-align: right"&gt;115 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;1,042 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;18.9 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;20.1 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;19.5 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: right"&gt;0.45&lt;/td&gt;
&lt;td style="text-align: right"&gt;0.37&lt;/td&gt;
&lt;td style="text-align: right"&gt;380&lt;/td&gt;
&lt;td style="text-align: right"&gt;116 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;295 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;19.5 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;20.9 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;20.2 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: right"&gt;0.6&lt;/td&gt;
&lt;td style="text-align: right"&gt;0.45&lt;/td&gt;
&lt;td style="text-align: right"&gt;466&lt;/td&gt;
&lt;td style="text-align: right"&gt;123 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;3,983 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;21.0 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;28.1 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;21.7 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: right"&gt;∞&lt;/td&gt;
&lt;td style="text-align: right"&gt;0.88&lt;/td&gt;
&lt;td style="text-align: right"&gt;901&lt;/td&gt;
&lt;td style="text-align: right"&gt;1,190 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;9,249 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;43.2 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;44.0 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;45.4 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;figcaption&gt;Table 7. Qwen3-32B online serving results (ISL = OSL = 1024 tokens).&lt;/figcaption&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="text-align: right"&gt;Req rate&lt;/th&gt;
&lt;th style="text-align: right"&gt;Actual req/s&lt;/th&gt;
&lt;th style="text-align: right"&gt;Output tok/s&lt;/th&gt;
&lt;th style="text-align: right"&gt;TTFT p50&lt;/th&gt;
&lt;th style="text-align: right"&gt;TTFT p99&lt;/th&gt;
&lt;th style="text-align: right"&gt;TPOT p50&lt;/th&gt;
&lt;th style="text-align: right"&gt;TPOT p99&lt;/th&gt;
&lt;th style="text-align: right"&gt;E2E p50&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="text-align: right"&gt;0.2&lt;/td&gt;
&lt;td style="text-align: right"&gt;0.19&lt;/td&gt;
&lt;td style="text-align: right"&gt;194&lt;/td&gt;
&lt;td style="text-align: right"&gt;509 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;33,147 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;12.3 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;15.4 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;13.1 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: right"&gt;0.3&lt;/td&gt;
&lt;td style="text-align: right"&gt;0.28&lt;/td&gt;
&lt;td style="text-align: right"&gt;283&lt;/td&gt;
&lt;td style="text-align: right"&gt;507 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;1,185 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;12.8 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;14.3 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;13.7 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: right"&gt;0.4&lt;/td&gt;
&lt;td style="text-align: right"&gt;0.36&lt;/td&gt;
&lt;td style="text-align: right"&gt;367&lt;/td&gt;
&lt;td style="text-align: right"&gt;506 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;1,099 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;13.4 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;15.7 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;14.4 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: right"&gt;0.45&lt;/td&gt;
&lt;td style="text-align: right"&gt;0.40&lt;/td&gt;
&lt;td style="text-align: right"&gt;407&lt;/td&gt;
&lt;td style="text-align: right"&gt;507 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;1,141 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;13.8 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;15.9 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;14.6 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: right"&gt;∞&lt;/td&gt;
&lt;td style="text-align: right"&gt;0.14&lt;/td&gt;
&lt;td style="text-align: right"&gt;143&lt;/td&gt;
&lt;td style="text-align: right"&gt;6,762 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;219,855 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;133.8 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;237.2 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;206.8 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;figcaption&gt;Table 8. Qwen3.5-4B online serving results (ISL = OSL = 1024 tokens).&lt;/figcaption&gt;
&lt;p&gt;The MoE model (30B-A3B) has a stable working zone up to about 0.6 req/s, where p50 TPOT stays below 12 ms and p99 TTFT stays below 1.4 seconds. At 0.8 req/s the server is already queuing: p99 TTFT jumps to 6.3 seconds while p50 TPOT jumps from 11 ms to 19 ms. Saturation at ∞ rate gives tight p99/p50 TPOT (22.8 ms versus 22.3 ms), meaning the server is consistently loaded with no idle cycles.&lt;/p&gt;
&lt;p&gt;The dense 32B model’s TPOT p50 is nearly flat from 0.15 to 0.6 req/s (18–21 ms), then doubles at saturation (43.2 ms). Its TTFT p99 is noisy across the sweep, suggesting occasional prefill bursts even at low load.&lt;/p&gt;
&lt;p&gt;The 4B model holds stable from 0.2 to 0.45 req/s. At 0.45 req/s it sustains 0.40 req/s and 407 output tok/s, with TPOT p50 of 13.8 ms and E2E p50 of 14.6 seconds. Under unlimited load it collapses entirely: output throughput drops from 407 tok/s to 143 tok/s, p50 TPOT rises from 13.8 ms to 133.8 ms, and p50 E2E latency reaches 206.8 seconds. The p99 TTFT at unlimited load is 219,855 ms — 220 seconds. This is not a degraded state; it is a broken one. We treat the unlimited-load point as a single-chip concurrency limit, not useful serving capacity.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th style="text-align: right"&gt;p50 TTFT&lt;/th&gt;
&lt;th style="text-align: right"&gt;p50 E2E latency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-30B-A3B&lt;/td&gt;
&lt;td style="text-align: right"&gt;712 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;23.6 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-32B&lt;/td&gt;
&lt;td style="text-align: right"&gt;1,190 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;45.4 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3.5-4B&lt;/td&gt;
&lt;td style="text-align: right"&gt;507 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;14.6 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;figcaption&gt;Table 9. p50 TTFT and p50 E2E latency at the saturation or stable operating point.&lt;/figcaption&gt;
&lt;figure&gt;
&lt;img style="border-radius: 8px" src="https://www.junyi.dev/posts/tpu-v6e/figures/fig5_summary.png" alt="MoE-versus-dense efficiency summary"&gt;
&lt;figcaption&gt;Figure 6. Summary relative to the dense Qwen3-32B baseline. Higher is better.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Across all three tests, the MoE model beats the dense 32B model at similar total parameter count:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th style="text-align: right"&gt;Qwen3-30B-A3B&lt;/th&gt;
&lt;th style="text-align: right"&gt;Qwen3-32B&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Best prefill throughput&lt;/td&gt;
&lt;td style="text-align: right"&gt;26,063 tok/s&lt;/td&gt;
&lt;td style="text-align: right"&gt;19,714 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BS1 decode TPOT&lt;/td&gt;
&lt;td style="text-align: right"&gt;7.0 ms&lt;/td&gt;
&lt;td style="text-align: right"&gt;17.9 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Serving capacity&lt;/td&gt;
&lt;td style="text-align: right"&gt;1.27 req/s&lt;/td&gt;
&lt;td style="text-align: right"&gt;0.88 req/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Output throughput at saturation&lt;/td&gt;
&lt;td style="text-align: right"&gt;1,303 tok/s&lt;/td&gt;
&lt;td style="text-align: right"&gt;901 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;figcaption&gt;Table 10. Summary comparison of Qwen3-30B-A3B versus Qwen3-32B across all three tests.&lt;/figcaption&gt;
&lt;p&gt;The MoE model gives about 2.5× lower single-stream decode latency, about 1.4× higher serving capacity, and about 1.3× higher prefill throughput.&lt;/p&gt;
&lt;h2 id="4-discussion" &gt;
&lt;div&gt;
&lt;a href="#4-discussion"&gt;
#
&lt;/a&gt;
4. Discussion
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;Three points stand out.&lt;/p&gt;
&lt;p&gt;First, active parameter count matters a lot for decode. The 30B-A3B MoE and the 32B dense model have similar total size, but very different active size. The MoE model uses 3B parameters per token. The dense model uses 32B. This explains the large decode gap: 7.0 ms versus 17.9 ms at batch size 1.&lt;/p&gt;
&lt;p&gt;Second, the MoE model also wins in prefill. Its peak prefill throughput is 26,063 tok/s, or about 6.5k tok/s per chip. The dense 32B model reaches 19,714 tok/s, or about 4.9k tok/s per chip. On this host, the MoE model is faster in both prefill and decode.&lt;/p&gt;
&lt;p&gt;Third, the small 4B model is limited by single-chip concurrency. It has good low-load latency, but one chip cannot absorb unlimited requests. Its practical serving capacity is about 0.45 req/s. To serve more traffic, the better path is to add chips or replicas, rather than pushing more concurrency onto one chip.&lt;/p&gt;
&lt;h2 id="5-conclusion" &gt;
&lt;div&gt;
&lt;a href="#5-conclusion"&gt;
#
&lt;/a&gt;
5. Conclusion
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;On one TPU v6e-4 host, using &lt;code&gt;vLLM 0.20.0&lt;/code&gt;, &lt;code&gt;tpu-inference&lt;/code&gt;, and an &lt;code&gt;fp8_e5m2&lt;/code&gt; KV cache, the large MoE model is the fastest of the three models we tested.&lt;/p&gt;
&lt;p&gt;Compared with the dense 32B model, Qwen3-30B-A3B has:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;higher prefill throughput: 26,063 vs 19,714 tok/s,&lt;/li&gt;
&lt;li&gt;lower batch-size-1 decode latency: 7.0 vs 17.9 ms,&lt;/li&gt;
&lt;li&gt;higher serving capacity: 1.27 vs 0.88 req/s,&lt;/li&gt;
&lt;li&gt;higher output throughput at saturation: 1,303 vs 901 tok/s.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The 4B dense model has good latency at low load, but on one chip it is concurrency-limited. Its stable serving capacity is about 0.45 req/s.&lt;/p&gt;
&lt;h2 id="limitations" &gt;
&lt;div&gt;
&lt;a href="#limitations"&gt;
#
&lt;/a&gt;
Limitations
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;These results are for one host only: four TPU v6e chips in one VM. We did not test multi-host scaling.&lt;/p&gt;
&lt;p&gt;We did not run a GPU baseline, so this study makes no TPU-versus-GPU claim.&lt;/p&gt;
&lt;p&gt;The online serving test uses one input/output shape: 1024 input tokens and 1024 output tokens. Other workloads may shift the balance between prefill and decode.&lt;/p&gt;
&lt;p&gt;The results are version-specific. TPU support in vLLM changes quickly, so the numbers here apply to &lt;code&gt;vLLM 0.20.0&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;All runs use an &lt;code&gt;fp8_e5m2&lt;/code&gt; KV cache. We did not test other KV cache formats.&lt;/p&gt;
&lt;h2 id="reproducibility" &gt;
&lt;div&gt;
&lt;a href="#reproducibility"&gt;
#
&lt;/a&gt;
Reproducibility
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;The main numbers are &lt;a href="https://www.junyi.dev/posts/tpu-v6e/data.json" target="_blank" rel="noopener noreferrer"&gt;./data.json&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Figures are generated by &lt;a href="https://www.junyi.dev/posts/tpu-v6e/figures/plot.py" target="_blank" rel="noopener noreferrer"&gt;./figures/plot.py&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Per-model results are at &lt;a href="https://www.junyi.dev/posts/tpu-v6e/rawdata.zip" target="_blank" rel="noopener noreferrer"&gt;./rawdata.zip&lt;/a&gt;&lt;/p&gt;</content:encoded><category>TPU</category><guid isPermaLink="true">https://www.junyi.dev/en/posts/tpu-v6e/</guid></item><item><title>Debunking introl and ainewshub: "TPU is 4x Cheaper than GPU" is an AI Hallucination</title><link>https://www.junyi.dev/en/posts/tpu-tco/</link><pubDate>Wed, 10 Jun 2026 15:26:00 +0800</pubDate><author>junyi.h@comp.nus.edu.sg (Junyi Hou)</author><description>
Disclaimer: this article is not arguing that TPU is worse than GPU. TPU and GPU each have their own fit, and which one wins depends on the specific workload. My point is that the batch of comparison data currently circulating online is itself inaccurate, much of it AI-fabricated and impossible to trace. What I&amp;amp;rsquo;m taking apart below is exactly that fake data.
Artificial Analysis recently released a set of hardware benchmarks1. Using Llama 3.3 70B, vLLM, and a reference speed of 30 output tokens/s per query to compute the cost per million input/output tokens, NVIDIA has roughly a 5x per-dollar token advantage over TPU v6e (Trillium), and roughly a 2x advantage over AMD MI300X2.
The hardware benchmark conclusion Artificial Analysis posted on X. NVIDIA has about a 5x per-dollar token advantage over TPU v6e and about 2x over MI300X. H100 is $1.06, MI300X is $2.24, TPU v6e is $5.13.
Alongside this reproducible data, another kind of thing is also circulating online.
# A High-Profile Comparison Article introl has an article titled Google TPU v6e vs GPU: 4x Better AI Performance Per Dollar3. Its core argument is that TPU has 4x better performance per dollar than H100, and that TPU completely beats NVIDIA on inference economics. Its key data comes from another article, ainewshub.org&amp;amp;rsquo;s Nvidia vs Google TPU 2025 Cost Comparison4. Follow this citation chain down and you find that both are AI-generated, and the data is made up.</description><content:encoded>&lt;blockquote&gt;
&lt;p&gt;Disclaimer: this article is not arguing that TPU is worse than GPU. TPU and GPU each have their own fit, and which one wins depends on the specific workload. My point is that the batch of comparison data currently circulating online is itself inaccurate, much of it AI-fabricated and impossible to trace. What I&amp;rsquo;m taking apart below is exactly that fake data.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Artificial Analysis recently released a set of hardware benchmarks&lt;sup id="fnref:1"&gt;&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref"&gt;1&lt;/a&gt;&lt;/sup&gt;. Using Llama 3.3 70B, vLLM, and a reference speed of 30 output tokens/s per query to compute the cost per million input/output tokens, NVIDIA has roughly a 5x per-dollar token advantage over TPU v6e (Trillium), and roughly a 2x advantage over AMD MI300X&lt;sup id="fnref:2"&gt;&lt;a href="#fn:2" class="footnote-ref" role="doc-noteref"&gt;2&lt;/a&gt;&lt;/sup&gt;.&lt;/p&gt;
&lt;figure&gt;&lt;img src="https://www.junyi.dev/posts/tpu-tco/artificial-analysis-twitter.png"
alt="The hardware benchmark conclusion Artificial Analysis posted on X" width="430"&gt;&lt;figcaption&gt;
&lt;p&gt;The hardware benchmark conclusion Artificial Analysis posted on X. NVIDIA has about a 5x per-dollar token advantage over TPU v6e and about 2x over MI300X. H100 is $1.06, MI300X is $2.24, TPU v6e is $5.13.&lt;/p&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Alongside this reproducible data, another kind of thing is also circulating online.&lt;/p&gt;
&lt;h2 id="a-high-profile-comparison-article" &gt;
&lt;div&gt;
&lt;a href="#a-high-profile-comparison-article"&gt;
#
&lt;/a&gt;
A High-Profile Comparison Article
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;introl has an article titled Google TPU v6e vs GPU: 4x Better AI Performance Per Dollar&lt;sup id="fnref:3"&gt;&lt;a href="#fn:3" class="footnote-ref" role="doc-noteref"&gt;3&lt;/a&gt;&lt;/sup&gt;. Its core argument is that TPU has 4x better performance per dollar than H100, and that TPU completely beats NVIDIA on inference economics. Its key data comes from another article, ainewshub.org&amp;rsquo;s Nvidia vs Google TPU 2025 Cost Comparison&lt;sup id="fnref:4"&gt;&lt;a href="#fn:4" class="footnote-ref" role="doc-noteref"&gt;4&lt;/a&gt;&lt;/sup&gt;. Follow this citation chain down and you find that both are AI-generated, and the data is made up.&lt;/p&gt;
&lt;h2 id="the-first-problem-the-core-citation-points-to-data-that-doesnt-exist" &gt;
&lt;div&gt;
&lt;a href="#the-first-problem-the-core-citation-points-to-data-that-doesnt-exist"&gt;
#
&lt;/a&gt;
The First Problem: The Core Citation Points to Data That Doesn&amp;rsquo;t Exist
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;The most central sentence in the ainewshub article reads like this.&lt;/p&gt;
&lt;figure&gt;&lt;img src="https://www.junyi.dev/posts/tpu-tco/hallucination-1.png"
alt="The core claim in the ainewshub article and the source it claims"&gt;&lt;figcaption&gt;
&lt;p&gt;The core claim in the ainewshub article, 4.7x better performance per dollar, but the source it cites is &lt;strong&gt;a non-existent&lt;/strong&gt; MLPerf v4.1 LLM inference result.&lt;/p&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;&amp;ldquo;4.7× better performance-per-dollar on LLM inference than Nvidia H100/H200&amp;rdquo;, with the source listed as Google Cloud MLPerf Inference v4.1 results + customer case studies, October 2025.&lt;/p&gt;
&lt;p&gt;But in fact, in MLPerf Inference v4.1, Google&amp;rsquo;s TPU submissions include only one model, stable-diffusion-xl. I went to MLCommons&amp;rsquo; official results&lt;sup id="fnref:5"&gt;&lt;a href="#fn:5" class="footnote-ref" role="doc-noteref"&gt;5&lt;/a&gt;&lt;/sup&gt; and filtered by Google plus TPU. Under v4.1 Closed Datacenter there are only two records, tpu-v5e-4 and tpu-v6-4, and both ran stable-diffusion-xl.&lt;/p&gt;
&lt;figure&gt;&lt;img src="https://www.junyi.dev/posts/tpu-tco/mlperf-for-tpu-1.png"
alt="Filtering MLPerf v4.1 for Google TPU shows only stable-diffusion-xl"&gt;&lt;figcaption&gt;
&lt;p&gt;Filtering MLPerf v4.1 Closed Datacenter for Google TPU shows only two entries, tpu-v5e-4 and tpu-v6-4, both running stable-diffusion-xl. There is no LLM inference entry at all.&lt;/p&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;No LLM inference entry at all! The so-called MLPerf v4.1 LLM inference 4.7x performance per dollar cited by this article &lt;strong&gt;simply does not exist in the source it claims&lt;/strong&gt;. The number was generated out of thin air and then attached to an authoritative-looking source.&lt;/p&gt;
&lt;p&gt;Second, &lt;strong&gt;MLPerf does not report performance per dollar at all&lt;/strong&gt;. It reports throughput, samples/s and queries/s, with no pricing in it. So that 4.7x &lt;strong&gt;could not possibly&lt;/strong&gt; have been computed from MLPerf.&lt;/p&gt;
&lt;h2 id="the-second-problem-the-numbers-drift-between-reposts" &gt;
&lt;div&gt;
&lt;a href="#the-second-problem-the-numbers-drift-between-reposts"&gt;
#
&lt;/a&gt;
The Second Problem: The Numbers Drift Between Reposts
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;Put introl&lt;sup id="fnref1:3"&gt;&lt;a href="#fn:3" class="footnote-ref" role="doc-noteref"&gt;3&lt;/a&gt;&lt;/sup&gt; and ainewshub&lt;sup id="fnref1:4"&gt;&lt;a href="#fn:4" class="footnote-ref" role="doc-noteref"&gt;4&lt;/a&gt;&lt;/sup&gt; side by side and the numbers don&amp;rsquo;t match (and there are false statements).&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Performance per dollar multiple: introl writes 4x, ainewshub writes 4.7x. (This one is pure deception, see above.)&lt;/li&gt;
&lt;li&gt;MLPerf version: introl cites v3.1, ainewshub cites v4.1. (No idea where introl got its data.)&lt;/li&gt;
&lt;li&gt;Midjourney case: introl writes monthly spend dropped from $2 million to $700k, ainewshub writes from $2.1 million to $700k. (I didn&amp;rsquo;t carefully verify this one, but it&amp;rsquo;s most likely hallucinated too.)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Reposted again and again, it changes a little each time it&amp;rsquo;s retold. Damn it, every time the model generates it, it makes up a slightly different number.&lt;/p&gt;
&lt;h2 id="the-third-problem-a-frighteningly-precise-tco-table-with-no-source" &gt;
&lt;div&gt;
&lt;a href="#the-third-problem-a-frighteningly-precise-tco-table-with-no-source"&gt;
#
&lt;/a&gt;
The Third Problem: A Frighteningly Precise TCO Table, With No Source
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;ainewshub&lt;sup id="fnref2:4"&gt;&lt;a href="#fn:4" class="footnote-ref" role="doc-noteref"&gt;4&lt;/a&gt;&lt;/sup&gt; gives a three-year total cost of ownership table, for a 1000-chip cluster, with NVIDIA H100 total cost $177 million, Google TPU v6 total cost $78.5 million, saving $98.5 million. It even breaks it down to hardware down 48%, power down 66%, cooling down 67%, support down 63%, networking down 67%, real estate down 63%. (All made up.)&lt;/p&gt;
&lt;p&gt;This kind of breakdown precise to the single percentage point looks very professional. The problem is that not one item can be traced to a source. The accompanying customer cases are the same. Midjourney, and a so-called Series C computer vision startup whose monthly spend dropped from $340k to $89k, are all unverifiable specific numbers.&lt;/p&gt;
&lt;h2 id="the-most-infuriating-part-is-data-verified" &gt;
&lt;div&gt;
&lt;a href="#the-most-infuriating-part-is-data-verified"&gt;
#
&lt;/a&gt;
The Most Infuriating Part is &amp;ldquo;data verified&amp;rdquo;
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;Following the ainewshub cost comparison article further upstream, its source points to another article on &lt;strong&gt;the same site&lt;/strong&gt;, AI Inference Costs: TPU vs GPU 2025&lt;sup id="fnref:6"&gt;&lt;a href="#fn:6" class="footnote-ref" role="doc-noteref"&gt;6&lt;/a&gt;&lt;/sup&gt;. The same numbers are repeated here again, 4x value for money, Midjourney saving 65%, TPU v5e winning 8 out of 9 items. I have to stress again, this data is fake, it does not exist.&lt;/p&gt;
&lt;p&gt;The statement at the end reads like this.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Data verified as of November 26, 2025. Sources include Google Cloud documentation, MLPerf benchmarks, company earnings reports, and verified industry migrations.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;Verified? Nothing here was verified.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;It says it verified, yet it can&amp;rsquo;t provide a single clickable link, no specific report, no methodology. As checked above, the MLPerf TPU LLM inference results it relies on &lt;strong&gt;simply do not exist&lt;/strong&gt;. The so-called verified is all completely fake.&lt;/p&gt;
&lt;p&gt;Different articles on the same site can&amp;rsquo;t even keep these fake numbers consistent. The person who wrote them doesn&amp;rsquo;t know where the numbers came from either, because they never existed in the first place.&lt;/p&gt;
&lt;p&gt;A site that mass-produces hallucinated numbers and then stamps them all with data verified &amp;hellip; this garbage site does real damage.&lt;/p&gt;
&lt;h2 id="the-data-ive-found-so-far-at-least-a-bit-more-credible-than-their-site" &gt;
&lt;div&gt;
&lt;a href="#the-data-ive-found-so-far-at-least-a-bit-more-credible-than-their-site"&gt;
#
&lt;/a&gt;
The Data I&amp;rsquo;ve Found So Far (At Least a Bit More Credible Than Their Site)
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;Artificial Analysis&amp;rsquo; System Load Test&lt;sup id="fnref1:1"&gt;&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref"&gt;1&lt;/a&gt;&lt;/sup&gt;, running Llama 3.3 70B.&lt;/p&gt;
&lt;figure&gt;&lt;img src="https://www.junyi.dev/posts/tpu-tco/artificial-analysis-throughput-test.png"
alt="Artificial Analysis System Load Test results"&gt;&lt;figcaption&gt;
&lt;p&gt;Artificial Analysis System Load Test, peak system throughput, per-query output speed, and on-demand rental price for Llama 3.3 70B.&lt;/p&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Peak system throughput: B200 is 15.4k tokens/s, H200 is 8.47k, H100 is 7.28k, TPU v6e is 6.73k, MI300X is 3.67k. TPU v6e falls behind NVIDIA&amp;rsquo;s same generation. Per-query output speed, TPU v6e is 61.3 tokens/s, the slowest in this group.&lt;/p&gt;
&lt;p&gt;Cost has to be discussed in two cases, and here TPU does have a favorable scenario too.&lt;/p&gt;
&lt;figure&gt;&lt;img src="https://www.junyi.dev/posts/tpu-tco/artificial-analysis-costs.png"
alt="Cost per million tokens for Llama 3.3 70B at peak throughput"&gt;&lt;figcaption&gt;
&lt;p&gt;Cost per million tokens for Llama 3.3 70B at peak throughput. TPU v6e is $0.62, close to H100&amp;rsquo;s $0.67 to $0.69.&lt;/p&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Computing cost per million tokens at peak throughput, TPU v6e is $0.62, close to H100&amp;rsquo;s $0.67 to $0.69, and cheaper than MI300X&amp;rsquo;s $0.90 and B200 running vLLM at $1.63. &lt;strong&gt;If the workload is offline large-batch and can keep the chips fed, TPU&amp;rsquo;s economics work out.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;But online serving rarely runs at peak throughput. Once you require a usable interactive speed, for example the reference speed of 30 tokens/s per query, TPU v6e&amp;rsquo;s unit cost jumps to $5.13, while H100 is $1.06. This is where the roughly 5x gap Artificial Analysis mentions comes from.&lt;/p&gt;
&lt;p&gt;Later we&amp;rsquo;ll run the benchmark ourselves to get first-hand data, and compare then.&lt;/p&gt;
&lt;h2 id="how-to-spot-ai-generated-junk-articles" &gt;
&lt;div&gt;
&lt;a href="#how-to-spot-ai-generated-junk-articles"&gt;
#
&lt;/a&gt;
How to Spot AI-Generated Junk Articles
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Just follow one principle: any data, any number, must have a clickable source and a reproducible method.&lt;/strong&gt;&lt;/p&gt;
&lt;div class="footnotes" role="doc-endnotes"&gt;
&lt;hr&gt;
&lt;ol&gt;
&lt;li id="fn:1"&gt;
&lt;p&gt;&lt;a href="https://artificialanalysis.ai/benchmarks/hardware" target="_blank" rel="noopener noreferrer"&gt;Artificial Analysis, hardware benchmarks&lt;/a&gt;&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&amp;#160;&lt;a href="#fnref1:1" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id="fn:2"&gt;
&lt;p&gt;&lt;a href="https://x.com/ArtificialAnlys/status/1993878037226557519" target="_blank" rel="noopener noreferrer"&gt;Artificial Analysis&amp;rsquo; original post on X&lt;/a&gt;&amp;#160;&lt;a href="#fnref:2" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id="fn:3"&gt;
&lt;p&gt;&lt;a href="https://introl.com/blog/google-tpu-v6e-vs-gpu-4x-better-ai-performance-per-dollar-guide" target="_blank" rel="noopener noreferrer"&gt;introl, Google TPU v6e vs GPU: 4x Better AI Performance Per Dollar&lt;/a&gt;&amp;#160;&lt;a href="#fnref:3" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&amp;#160;&lt;a href="#fnref1:3" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id="fn:4"&gt;
&lt;p&gt;&lt;a href="https://www.ainewshub.org/post/nvidia-vs-google-tpu-2025-cost-comparison" target="_blank" rel="noopener noreferrer"&gt;ainewshub.org, Nvidia vs Google TPU 2025 Cost Comparison&lt;/a&gt;&amp;#160;&lt;a href="#fnref:4" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&amp;#160;&lt;a href="#fnref1:4" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&amp;#160;&lt;a href="#fnref2:4" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id="fn:5"&gt;
&lt;p&gt;&lt;a href="https://mlcommons.org/benchmarks/inference-datacenter/" target="_blank" rel="noopener noreferrer"&gt;MLCommons, MLPerf Inference Datacenter official results&lt;/a&gt;&amp;#160;&lt;a href="#fnref:5" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id="fn:6"&gt;
&lt;p&gt;&lt;a href="https://www.ainewshub.org/post/ai-inference-costs-tpu-vs-gpu-2025" target="_blank" rel="noopener noreferrer"&gt;ainewshub.org, AI Inference Costs: TPU vs GPU 2025&lt;/a&gt;&amp;#160;&lt;a href="#fnref:6" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;</content:encoded><category>TPU</category><category>Linux</category><category>AI Systems</category><category>Tech</category><guid isPermaLink="true">https://www.junyi.dev/en/posts/tpu-tco/</guid></item><item><title>Claude Code Asks to Re-login in tmux? It's Probably macOS Keychain</title><link>https://www.junyi.dev/en/posts/tmux-keychain/</link><pubDate>Fri, 03 Apr 2026 00:00:00 +0800</pubDate><author>junyi.h@comp.nus.edu.sg (Junyi Hou)</author><description>
claude works fine in my terminal — Team Account, auth, everything. Open tmux, run claude, and it wants me to log in again. Took a bit of digging to figure out it&amp;amp;rsquo;s a macOS Security Session thing.</description><content:encoded>&lt;p&gt;&lt;code&gt;claude&lt;/code&gt; works fine in my terminal — Team Account, auth, everything. Open tmux, run &lt;code&gt;claude&lt;/code&gt;, and it wants me to log in again. Took a bit of digging to figure out it&amp;rsquo;s a macOS Security Session thing.&lt;/p&gt;
&lt;h2 id="what-happens" &gt;
&lt;div&gt;
&lt;a href="#what-happens"&gt;
#
&lt;/a&gt;
What happens
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;claude&lt;/code&gt; in Ghostty: works. &lt;code&gt;claude&lt;/code&gt; in tmux: asks to re-login.&lt;/p&gt;
&lt;details&gt;
&lt;summary&gt;How I tracked it down (click to expand)&lt;/summary&gt;
&lt;h3 id="environment-variables" &gt;
&lt;div&gt;
&lt;a href="#environment-variables"&gt;
##
&lt;/a&gt;
Environment variables?
&lt;/div&gt;
&lt;/h3&gt;
&lt;p&gt;First thought: tmux has different env vars.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#e2e4e5;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;env | grep -iE &lt;span style="color:#5af78e"&gt;&amp;#39;claude|anthropic&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Nothing on either side. Claude Code doesn&amp;rsquo;t store auth in env vars.&lt;/p&gt;
&lt;h3 id="config-files" &gt;
&lt;div&gt;
&lt;a href="#config-files"&gt;
##
&lt;/a&gt;
Config files?
&lt;/div&gt;
&lt;/h3&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#e2e4e5;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;cat ~/.claude/.credentials.json 2&amp;gt;/dev/null &lt;span style="color:#ff6ac1"&gt;||&lt;/span&gt; &lt;span style="color:#ff5c57"&gt;echo&lt;/span&gt; &lt;span style="color:#5af78e"&gt;&amp;#34;no credentials file&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Nope. Not on disk either.&lt;/p&gt;
&lt;h3 id="keychain" &gt;
&lt;div&gt;
&lt;a href="#keychain"&gt;
##
&lt;/a&gt;
Keychain
&lt;/div&gt;
&lt;/h3&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#e2e4e5;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;security dump-keychain 2&amp;gt;&amp;amp;&lt;span style="color:#ff9f43"&gt;1&lt;/span&gt; | grep -i -A3 &lt;span style="color:#5af78e"&gt;&amp;#39;claude\|anthropic&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Found it — stored in macOS Keychain as &lt;code&gt;Claude Code-credentials&lt;/code&gt;:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;0x00000007 &amp;lt;blob&amp;gt;=&amp;#34;Claude Code-credentials&amp;#34;
&amp;#34;acct&amp;#34;&amp;lt;blob&amp;gt;=&amp;#34;junyi&amp;#34;
&amp;#34;svce&amp;#34;&amp;lt;blob&amp;gt;=&amp;#34;Claude Code-credentials&amp;#34;
keychain: &amp;#34;/Users/junyi/Library/Keychains/login.keychain-db&amp;#34;
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Then:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#e2e4e5;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;security find-generic-password -s &lt;span style="color:#5af78e"&gt;&amp;#34;Claude Code-credentials&amp;#34;&lt;/span&gt; -a &lt;span style="color:#5af78e"&gt;&amp;#34;junyi&amp;#34;&lt;/span&gt; -w
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Works in the terminal, errors in tmux. There it is.&lt;/p&gt;
&lt;/details&gt;
&lt;h2 id="why-this-happens" &gt;
&lt;div&gt;
&lt;a href="#why-this-happens"&gt;
#
&lt;/a&gt;
Why this happens
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;macOS Keychain access is tied to a &lt;strong&gt;Security Session&lt;/strong&gt; (Bootstrap Namespace). When you open a terminal window, the process gets attached to your &lt;strong&gt;Aqua session&lt;/strong&gt;, which holds the Keychain unlock state.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;tmux server&lt;/code&gt; is a long-running daemon — it starts on your first &lt;code&gt;tmux new&lt;/code&gt; and stays alive. When you &lt;code&gt;tmux attach&lt;/code&gt; later, shells forked inside tmux server inherit &lt;strong&gt;the server&amp;rsquo;s original session&lt;/strong&gt;, not your current Aqua session.&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;Normal terminal:
Ghostty → fork shell → inherits Aqua session → ✅ Keychain works
tmux:
Ghostty → attach → tmux server (old process)
→ fork shell → inherits old session
→ ❌ Keychain blocked
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Same reason &lt;code&gt;pbcopy&lt;/code&gt;/&lt;code&gt;pbpaste&lt;/code&gt; break inside tmux.&lt;/p&gt;
&lt;h2 id="fix" &gt;
&lt;div&gt;
&lt;a href="#fix"&gt;
#
&lt;/a&gt;
Fix
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;Install &lt;code&gt;reattach-to-user-namespace&lt;/code&gt; to reconnect tmux processes to your Aqua session:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#e2e4e5;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;brew install reattach-to-user-namespace
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Add to &lt;code&gt;~/.tmux.conf&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#e2e4e5;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;set-option -g default-command &lt;span style="color:#5af78e"&gt;&amp;#34;reattach-to-user-namespace -l &lt;/span&gt;&lt;span style="color:#5af78e"&gt;${&lt;/span&gt;&lt;span style="color:#ff5c57"&gt;SHELL&lt;/span&gt;&lt;span style="color:#5af78e"&gt;}&lt;/span&gt;&lt;span style="color:#5af78e"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then &lt;strong&gt;kill the entire tmux server&lt;/strong&gt; (kill-session won&amp;rsquo;t help — new windows still fork from the old server):&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#e2e4e5;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;tmux kill-server
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;tmux new -s main
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Verify:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#e2e4e5;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;security find-generic-password -s &lt;span style="color:#5af78e"&gt;&amp;#34;Claude Code-credentials&amp;#34;&lt;/span&gt; -a &lt;span style="color:#5af78e"&gt;&amp;#34;&lt;/span&gt;&lt;span style="color:#ff5c57"&gt;$USER&lt;/span&gt;&lt;span style="color:#5af78e"&gt;&amp;#34;&lt;/span&gt; -w 2&amp;gt;&amp;amp;&lt;span style="color:#ff9f43"&gt;1&lt;/span&gt; | head -c &lt;span style="color:#ff9f43"&gt;50&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;If it prints the token, you&amp;rsquo;re good.&lt;/p&gt;
&lt;h2 id="while-youre-at-it" &gt;
&lt;div&gt;
&lt;a href="#while-youre-at-it"&gt;
#
&lt;/a&gt;
While you&amp;rsquo;re at it
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;These tmux issues are probably the same root cause:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;pbcopy&lt;/code&gt;/&lt;code&gt;pbpaste&lt;/code&gt; broken&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ssh-agent&lt;/code&gt; inaccessible&lt;/li&gt;
&lt;li&gt;&lt;code&gt;osascript&lt;/code&gt; failing&lt;/li&gt;
&lt;li&gt;&lt;code&gt;gh auth&lt;/code&gt;, &lt;code&gt;op&lt;/code&gt;, and other Keychain-dependent CLI tools not working&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;reattach-to-user-namespace&lt;/code&gt; fixes all of them.&lt;/p&gt;</content:encoded><category>Linux</category><category>tmux</category><category>AI Systems</category><category>Keychain</category><category>Security Session</category><guid isPermaLink="true">https://www.junyi.dev/en/posts/tmux-keychain/</guid></item><item><title>Agent Mesh: My Thoughts on Multi-AI Collaborative Workflows</title><link>https://www.junyi.dev/en/posts/agent-mesh/</link><pubDate>Wed, 11 Mar 2026 04:29:00 +0800</pubDate><author>junyi.h@comp.nus.edu.sg (Junyi Hou)</author><description>
AI coding assistants are everywhere now, from chat windows to CLI Agents. After using a bunch of them, my biggest takeaway is: no single Agent is the best at everything.
I mainly use Claude Code, Google Gemini CLI, and Moonshot Kimi Code day-to-day. At first I treated them as standalone tools, but soon realized that was a waste — each has distinct strengths, and if you let them collaborate and delegate tasks based on those strengths, the results are much better. I call this approach Agent Mesh.
Here are my observations and thoughts.</description><content:encoded>&lt;p&gt;AI coding assistants are everywhere now, from chat windows to CLI Agents. After using a bunch of them, my biggest takeaway is: &lt;strong&gt;no single Agent is the best at everything.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;I mainly use &lt;strong&gt;Claude Code&lt;/strong&gt;, &lt;strong&gt;Google Gemini CLI&lt;/strong&gt;, and &lt;strong&gt;Moonshot Kimi Code&lt;/strong&gt; day-to-day. At first I treated them as standalone tools, but soon realized that was a waste — each has distinct strengths, and if you let them collaborate and delegate tasks based on those strengths, the results are much better. I call this approach &lt;strong&gt;Agent Mesh&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Here are my observations and thoughts.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="core-roles-my-observations-and-real-experience" &gt;
&lt;div&gt;
&lt;a href="#core-roles-my-observations-and-real-experience"&gt;
#
&lt;/a&gt;
Core Roles: My Observations and Real Experience
&lt;/div&gt;
&lt;/h2&gt;
&lt;h3 id="claude-code-top-tier-architect-and-execution-engine" &gt;
&lt;div&gt;
&lt;a href="#claude-code-top-tier-architect-and-execution-engine"&gt;
##
&lt;/a&gt;
Claude Code: Top-Tier Architect and Execution Engine
&lt;/div&gt;
&lt;/h3&gt;
&lt;p&gt;In daily use, Claude Code feels like a &amp;ldquo;senior engineer.&amp;rdquo; Whether spinning up a new project from scratch or tackling a complex refactor, its architectural vision is the best of the bunch. And it doesn&amp;rsquo;t just plan — it executes: traversing files, running bash, fixing its own compile errors, driving the whole loop autonomously. I&amp;rsquo;ve installed Claude Code on all my servers. It has massively boosted my productivity and freed up a lot of my energy. The weakness is hallucinations — especially when generating large blocks of documentation or referencing large systems it hasn&amp;rsquo;t fully digested, it will make things up.&lt;/p&gt;
&lt;h3 id="gemini-cli-large-context-consumer-and-hallucination-checker" &gt;
&lt;div&gt;
&lt;a href="#gemini-cli-large-context-consumer-and-hallucination-checker"&gt;
##
&lt;/a&gt;
Gemini CLI: Large-Context Consumer and &amp;ldquo;Hallucination Checker&amp;rdquo;
&lt;/div&gt;
&lt;/h3&gt;
&lt;p&gt;Gemini&amp;rsquo;s biggest advantage is that 2-million-token context window. In practice, it really can swallow an entire repository or very long error logs (I&amp;rsquo;ve tested with logs of several hundred KB). Because it can hold the entire macro context in its head, it&amp;rsquo;s a natural auditor. When Claude Code produces a brilliant but potentially hallucinated code plan, you can call on Gemini for large-scale cross-file verification.&lt;/p&gt;
&lt;h3 id="kimi-code-deep-reasoner-and-data-miner--pending-evaluation" &gt;
&lt;div&gt;
&lt;a href="#kimi-code-deep-reasoner-and-data-miner--pending-evaluation"&gt;
##
&lt;/a&gt;
Kimi Code: Deep Reasoner and Data Miner (❓ Pending Evaluation)
&lt;/div&gt;
&lt;/h3&gt;
&lt;p&gt;Kimi Code takes a completely different path. Under the hood it runs K2.5&amp;rsquo;s &amp;ldquo;long thinking&amp;rdquo; model (k0), using explicit chain-of-thought (similar to OpenAI o1&amp;rsquo;s approach), plus an &amp;ldquo;Agentic Swarm&amp;rdquo; pattern that can theoretically spin up a swarm of sub-Agents for multi-hop document retrieval or grinding through deeply nested algorithmic problems. I do have a question mark here though: I use Claude and Gemini every day, so I have a solid feel for their capability boundaries, but how much better Kimi actually is at &amp;ldquo;data mining&amp;rdquo; and &amp;ldquo;deep reasoning&amp;rdquo; compared to the other two — honestly, I&amp;rsquo;m mostly going by its architectural claims and haven&amp;rsquo;t done rigorous head-to-head evaluations yet. So for now, its niche here is only a theoretical one.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="what-my-ideal-agent-mesh-looks-like" &gt;
&lt;div&gt;
&lt;a href="#what-my-ideal-agent-mesh-looks-like"&gt;
#
&lt;/a&gt;
What My Ideal Agent Mesh Looks Like
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;&lt;img src="https://www.junyi.dev/posts/agent-mesh/diagram.svg" alt="Agent Mesh Workflow Diagram"&gt;&lt;/p&gt;
&lt;p&gt;Once you understand each Agent&amp;rsquo;s strengths and weaknesses, the natural next question is: can you have them collaborate, chaining tasks via prompts? That&amp;rsquo;s what I mean by Agent Mesh.&lt;/p&gt;
&lt;p&gt;Most of the time, Claude Code is the workhorse — it writes the vast majority of the code. But when it hits a context ceiling, say I need to refactor a module but don&amp;rsquo;t know which parts of a 500K-line legacy codebase will be affected, I hand it off to Gemini — let it read the entire repo and report back the blast radius.&lt;/p&gt;
&lt;p&gt;Conversely, after Claude executes a refactoring plan, I&amp;rsquo;ll have Gemini cross-reference it against the actual project files to catch hallucinations or broken dependencies. If Gemini encounters a particularly tough algorithmic problem during auditing, it can pass it to Kimi Code for extended reasoning.&lt;/p&gt;
&lt;p&gt;After Kimi finishes thinking, it doesn&amp;rsquo;t touch files directly — it passes conclusions back to Claude Code for implementation. The whole flow is a loop: each does what it&amp;rsquo;s best at, and results flow between them.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="the-hidden-factor-agent-architecture-vs-base-model" &gt;
&lt;div&gt;
&lt;a href="#the-hidden-factor-agent-architecture-vs-base-model"&gt;
#
&lt;/a&gt;
The Hidden Factor: Agent Architecture vs. Base Model
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;When trying to combine these Agents, I discovered a point that&amp;rsquo;s easy to overlook: &lt;strong&gt;the underlying base model is only half the equation — the &amp;ldquo;Agent architecture built around it&amp;rdquo; determines its actual capabilities.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The best example is Anthropic&amp;rsquo;s Claude 3.5 Sonnet. You can use this same base model in multiple environments:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;As a standard chat assistant in &lt;strong&gt;VS Code&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;As the driving engine inside &lt;strong&gt;Cursor IDE&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Through open-source Agent frameworks like &lt;strong&gt;OpenHands / Open Code&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Natively via the &lt;strong&gt;Claude Code CLI&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Despite sharing the exact same LLM DNA (Claude 3.5 Sonnet), their real-world capabilities differ dramatically. In demanding engineering workflows, native &lt;strong&gt;Claude Code&lt;/strong&gt; consistently outperforms the others in Agent execution. Why? Because the Agent architecture has been uniquely optimized by the model&amp;rsquo;s creators (including but not limited to: hidden loop mechanisms, error recovery strategies, and specific context engineering).&lt;/p&gt;
&lt;p&gt;Even using excellent open-source alternatives like Open Code with the Claude model, the execution fluidity and architectural vision often falls short of Claude Code. This tells us that when choosing Agents for a Mesh, we&amp;rsquo;re not just picking a base model (like Sonnet or GPT-4o) — we&amp;rsquo;re picking the engineering scaffolding built around it.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="similar-explorations-in-the-community" &gt;
&lt;div&gt;
&lt;a href="#similar-explorations-in-the-community"&gt;
#
&lt;/a&gt;
Similar Explorations in the Community
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;After writing this up, I found that others in the community are working on similar ideas. &lt;strong&gt;&lt;a href="https://github.com/humania-org/humanize" target="_blank" rel="noopener noreferrer"&gt;Humanize&lt;/a&gt;&lt;/strong&gt; is a Claude Code plugin that implements a workflow called &lt;strong&gt;RLCR&lt;/strong&gt; (Ralph-Loop with Codex Review): Claude writes the code, Codex independently reviews it, and if issues are found, it gets kicked back for rework, looping until everything passes. The core idea is the same as Agent Mesh: let architecturally different AIs each do what they&amp;rsquo;re best at, iterating through a work-feedback loop instead of expecting one model to nail everything in a single shot.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="final-thoughts" &gt;
&lt;div&gt;
&lt;a href="#final-thoughts"&gt;
#
&lt;/a&gt;
Final Thoughts
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;I don&amp;rsquo;t think a single &amp;ldquo;do-it-all&amp;rdquo; model will emerge to handle everything. What&amp;rsquo;s truly interesting is &lt;strong&gt;orchestration&lt;/strong&gt; — having Agents with &lt;strong&gt;fundamentally different underlying architectures&lt;/strong&gt; each play to their strengths.&lt;/p&gt;
&lt;p&gt;One view I&amp;rsquo;ve consistently held: when we say &amp;ldquo;different&amp;rdquo; agents, we do &lt;strong&gt;not&lt;/strong&gt; mean taking the same underlying model and wrapping it with different system prompts (e.g., role-playing &amp;ldquo;you are an architect&amp;rdquo; vs. &amp;ldquo;you are a tester&amp;rdquo;). True collaborative sparks only happen when agents have &lt;strong&gt;fundamental differences in how they process information at the architectural level&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;But how do you know whether two models are truly different, or just fine-tuned versions of the same base? This is where tools like &lt;strong&gt;&lt;a href="https://github.com/Xtra-Computing/LLM-DNA" target="_blank" rel="noopener noreferrer"&gt;LLM-DNA&lt;/a&gt;&lt;/strong&gt; (a research framework for analyzing evolutionary relationships and functional differences between language models, published in &lt;a href="https://openreview.net/forum?id=UIxHaAqFqQ" target="_blank" rel="noopener noreferrer"&gt;ICLR'26&lt;/a&gt;) become invaluable. By analyzing the &amp;ldquo;genetic&amp;rdquo; lineage and functional distance between models, we can deliberately select Agents from entirely different evolutionary branches, ensuring they don&amp;rsquo;t share the same blind spots.&lt;/p&gt;
&lt;p&gt;My hands-on engineering experience tells me: &lt;strong&gt;the Claude Code + Gemini CLI synergy is currently the most powerful and intuitive combination.&lt;/strong&gt; Gemini CLI leverages its long context to swallow entire repos and stacks of logs, specializing in catching hallucinations and macro-level auditing. Claude Code focuses on what it does best — understanding code architecture and executing precisely in the local environment.&lt;/p&gt;
&lt;p&gt;When you deliberately combine Agents with fundamentally different architectures, they stop overlapping and start complementing each other. That&amp;rsquo;s the Agent Mesh idea I wanted to share — hope it inspires some of you.&lt;/p&gt;</content:encoded><category>AI Systems</category><category>LLM Inference</category><category>Tech</category><guid isPermaLink="true">https://www.junyi.dev/en/posts/agent-mesh/</guid></item><item><title>nerdctl + Tailscale Sidecar Exit Node: A Complete Pitfall Guide</title><link>https://www.junyi.dev/en/posts/nerdctl-tailscale-sidecar-pitfalls/</link><pubDate>Fri, 06 Mar 2026 00:00:00 +0800</pubDate><author>junyi.h@comp.nus.edu.sg (Junyi Hou)</author><description>
In containerized deployments, sometimes you need all outbound traffic from a container to go through a specific network exit. Tailscale&amp;amp;rsquo;s sidecar pattern makes this possible: a Tailscale container acts as a sidecar, other containers share its network namespace, and traffic flows through a WireGuard tunnel via a remote exit node.
This setup is well-documented for Docker Compose, but migrating to nerdctl (containerd) introduced a series of pitfalls. Here&amp;amp;rsquo;s what I ran into so others can avoid the same traps.</description><content:encoded>&lt;p&gt;In containerized deployments, sometimes you need all outbound traffic from a container to go through a specific network exit. Tailscale&amp;rsquo;s sidecar pattern makes this possible: a Tailscale container acts as a sidecar, other containers share its network namespace, and traffic flows through a WireGuard tunnel via a remote exit node.&lt;/p&gt;
&lt;p&gt;This setup is well-documented for Docker Compose, but migrating to nerdctl (containerd) introduced a series of pitfalls. Here&amp;rsquo;s what I ran into so others can avoid the same traps.&lt;/p&gt;
&lt;h2 id="the-goal" &gt;
&lt;div&gt;
&lt;a href="#the-goal"&gt;
#
&lt;/a&gt;
The Goal
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;Route all outbound traffic from any container through a Tailscale sidecar, forwarding via a remote exit node:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://www.junyi.dev/posts/nerdctl-tailscale-sidecar-pitfalls/architecture.svg" alt="nerdctl &amp;#43; Tailscale Sidecar Architecture"&gt;&lt;/p&gt;
&lt;p&gt;For verification, I used &lt;code&gt;nicolaka/netshoot&lt;/code&gt; as a test container — it comes with curl, dig, tcpdump, iperf, and a full suite of networking tools out of the box.&lt;/p&gt;
&lt;h2 id="pitfall-1-nerdctl-compose-doesnt-support-network_mode-servicexxx" &gt;
&lt;div&gt;
&lt;a href="#pitfall-1-nerdctl-compose-doesnt-support-network_mode-servicexxx"&gt;
#
&lt;/a&gt;
Pitfall 1: nerdctl compose doesn&amp;rsquo;t support &lt;code&gt;network_mode: service:xxx&lt;/code&gt;
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;Docker Compose supports &lt;code&gt;network_mode: service:&amp;lt;service_name&amp;gt;&lt;/code&gt;, which automatically resolves the service name to the corresponding container&amp;rsquo;s network namespace. nerdctl compose hasn&amp;rsquo;t implemented this translation layer and throws an error:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;unsupported network_mode: service:tailscale-sidecar
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Use &lt;code&gt;network_mode: container:&amp;lt;container_name&amp;gt;&lt;/code&gt; instead, and pin the sidecar container&amp;rsquo;s name with &lt;code&gt;container_name&lt;/code&gt;.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#e2e4e5;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff6ac1"&gt;services&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff6ac1"&gt;tailscale-sidecar&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff6ac1"&gt;container_name&lt;/span&gt;: tailscale-sidecar &lt;span style="color:#78787e"&gt;# Pin the name&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#78787e"&gt;# ...&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff6ac1"&gt;netshoot&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff6ac1"&gt;network_mode&lt;/span&gt;: container:tailscale-sidecar &lt;span style="color:#78787e"&gt;# Use container name, not service name&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Without a fixed &lt;code&gt;container_name&lt;/code&gt;, nerdctl compose auto-generates names like &lt;code&gt;blockchain-tailscale-sidecar-1&lt;/code&gt;, causing reference mismatches.&lt;/p&gt;
&lt;h2 id="pitfall-2-undefined-tag-in-tailscale-acl" &gt;
&lt;div&gt;
&lt;a href="#pitfall-2-undefined-tag-in-tailscale-acl"&gt;
#
&lt;/a&gt;
Pitfall 2: Undefined tag in Tailscale ACL
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;After starting, the Tailscale container immediately exits with:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;Received error: requested tags [tag:container] are invalid or not permitted
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Define the tag in your Tailscale admin console&amp;rsquo;s ACL policy and select it when generating the auth key:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#e2e4e5;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#5af78e"&gt;&amp;#34;tagOwners&amp;#34;&lt;/span&gt;&lt;span style="color:#ff5c57"&gt;:&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff6ac1"&gt;&amp;#34;tag:container&amp;#34;&lt;/span&gt;: [&lt;span style="color:#5af78e"&gt;&amp;#34;autogroup:admin&amp;#34;&lt;/span&gt;]
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="pitfall-3-missing-sys_module-capability" &gt;
&lt;div&gt;
&lt;a href="#pitfall-3-missing-sys_module-capability"&gt;
#
&lt;/a&gt;
Pitfall 3: Missing &lt;code&gt;SYS_MODULE&lt;/code&gt; capability
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;This one is the sneakiest. The container joins the tailnet fine, &lt;code&gt;tailscale ping&lt;/code&gt; works, but after enabling the exit node, TCP traffic is completely broken — only ICMP gets through.&lt;/p&gt;
&lt;p&gt;The root cause: kernel mode (&lt;code&gt;TS_USERSPACE=false&lt;/code&gt;) needs to load kernel modules like &lt;code&gt;xt_mark&lt;/code&gt; and &lt;code&gt;nf_nat&lt;/code&gt; to set up iptables forwarding rules. Without &lt;code&gt;SYS_MODULE&lt;/code&gt; capability, these modules can&amp;rsquo;t load. The iptables rules are written but don&amp;rsquo;t take effect, resulting in working ICMP but broken TCP.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt;&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#e2e4e5;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff6ac1"&gt;cap_add&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - net_admin
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - SYS_MODULE &lt;span style="color:#78787e"&gt;# Essential&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="pitfall-4-dns-failure-in-exit-node-mode" &gt;
&lt;div&gt;
&lt;a href="#pitfall-4-dns-failure-in-exit-node-mode"&gt;
#
&lt;/a&gt;
Pitfall 4: DNS failure in exit node mode
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;With the exit node enabled, all outbound traffic goes through the WireGuard tunnel to the remote exit node. But &lt;code&gt;/etc/resolv.conf&lt;/code&gt; still points to local DNS servers (e.g., &lt;code&gt;10.0.2.3&lt;/code&gt;), which are unreachable from the exit node&amp;rsquo;s perspective. DNS queries naturally fail.&lt;/p&gt;
&lt;p&gt;The symptom: &lt;code&gt;curl ifconfig.me&lt;/code&gt; reports &lt;code&gt;Could not resolve host&lt;/code&gt;, while direct IP access hangs or returns errors.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Let Tailscale take over DNS so queries also go through the tunnel:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#e2e4e5;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff6ac1"&gt;environment&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - TS_ACCEPT_DNS=true
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="final-working-composeyaml" &gt;
&lt;div&gt;
&lt;a href="#final-working-composeyaml"&gt;
#
&lt;/a&gt;
Final Working compose.yaml
&lt;/div&gt;
&lt;/h2&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#e2e4e5;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#ff6ac1"&gt;services&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff6ac1"&gt;tailscale-sidecar&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff6ac1"&gt;image&lt;/span&gt;: tailscale/tailscale:latest
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff6ac1"&gt;container_name&lt;/span&gt;: tailscale-sidecar
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff6ac1"&gt;hostname&lt;/span&gt;: tailscale-sidecar
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff6ac1"&gt;environment&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - TS_AUTHKEY=${TS_AUTHKEY}
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - TS_EXTRA_ARGS=--advertise-tags=tag:container --exit-node=${TS_EXITNODE}
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - TS_STATE_DIR=/var/lib/tailscale
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - TS_USERSPACE=false
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - TS_ACCEPT_DNS=true
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff6ac1"&gt;volumes&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - ${PWD}/tailscale-sidecar/state:/var/lib/tailscale
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff6ac1"&gt;devices&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - /dev/net/tun:/dev/net/tun
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff6ac1"&gt;cap_add&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - net_admin
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - SYS_MODULE
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff6ac1"&gt;restart&lt;/span&gt;: unless-stopped
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff6ac1"&gt;netshoot&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff6ac1"&gt;image&lt;/span&gt;: nicolaka/netshoot
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff6ac1"&gt;depends_on&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - tailscale-sidecar
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff6ac1"&gt;network_mode&lt;/span&gt;: container:tailscale-sidecar
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#ff6ac1"&gt;command&lt;/span&gt;: sleep infinity
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Prepare a &lt;code&gt;.env&lt;/code&gt; file in the same directory:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;TS_AUTHKEY=tskey-auth-xxxxx
TS_EXITNODE=100.x.x.x
&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="verification" &gt;
&lt;div&gt;
&lt;a href="#verification"&gt;
#
&lt;/a&gt;
Verification
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;nicolaka/netshoot&lt;/code&gt; comes with plenty of network tools, perfect for validation:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#e2e4e5;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#78787e"&gt;# Check exit IP to confirm traffic goes through the exit node&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;nerdctl &lt;span style="color:#ff5c57"&gt;exec&lt;/span&gt; -it netshoot curl ifconfig.me
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#78787e"&gt;# DNS resolution test&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;nerdctl &lt;span style="color:#ff5c57"&gt;exec&lt;/span&gt; -it netshoot dig google.com
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#78787e"&gt;# View routing table&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;nerdctl &lt;span style="color:#ff5c57"&gt;exec&lt;/span&gt; -it netshoot ip route show table all
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#78787e"&gt;# Packet capture for debugging&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;nerdctl &lt;span style="color:#ff5c57"&gt;exec&lt;/span&gt; -it netshoot tcpdump -i any -n
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#78787e"&gt;# Tailscale status checks&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;nerdctl &lt;span style="color:#ff5c57"&gt;exec&lt;/span&gt; -it tailscale-sidecar tailscale status
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;nerdctl &lt;span style="color:#ff5c57"&gt;exec&lt;/span&gt; -it tailscale-sidecar tailscale exit-node status
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;nerdctl &lt;span style="color:#ff5c57"&gt;exec&lt;/span&gt; -it tailscale-sidecar tailscale ping &amp;lt;exit-node-ip&amp;gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="debugging-methodology" &gt;
&lt;div&gt;
&lt;a href="#debugging-methodology"&gt;
#
&lt;/a&gt;
Debugging Methodology
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;The debugging process itself is worth reviewing. When facing a compound problem like &amp;ldquo;exit node doesn&amp;rsquo;t work&amp;rdquo;, the effective approach is to isolate layer by layer:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Verify tunnel connectivity first:&lt;/strong&gt; &lt;code&gt;tailscale ping&lt;/code&gt; confirms whether the WireGuard tunnel itself works.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Comparative testing:&lt;/strong&gt; Disable the exit node to determine if the issue is with the exit node or the underlying network.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Differentiate protocols:&lt;/strong&gt; ICMP works but TCP doesn&amp;rsquo;t — points to iptables/kernel module issues.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Separate DNS from connectivity:&lt;/strong&gt; Use direct IP access to bypass DNS and isolate each issue.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Compare against known-working configs:&lt;/strong&gt; Spotting that a previously working setup had &lt;code&gt;SYS_MODULE&lt;/code&gt; immediately pinpointed the problem.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Each step narrows the scope, avoiding wasted time in the wrong direction.&lt;/p&gt;
&lt;h2 id="final-thoughts" &gt;
&lt;div&gt;
&lt;a href="#final-thoughts"&gt;
#
&lt;/a&gt;
Final Thoughts
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;nerdctl compose has matured as a Docker Compose alternative, but gaps remain in edge-case compatibility. The missing &lt;code&gt;network_mode: service:&lt;/code&gt; support and different container naming conventions are easy migration traps. For Tailscale in containers with exit node routing, &lt;code&gt;SYS_MODULE&lt;/code&gt; and &lt;code&gt;TS_ACCEPT_DNS&lt;/code&gt; are two easily overlooked but essential configurations. Hope this write-up helps anyone hitting the same issues.&lt;/p&gt;</content:encoded><category>Containers</category><category>Networking</category><category>Tech</category><guid isPermaLink="true">https://www.junyi.dev/en/posts/nerdctl-tailscale-sidecar-pitfalls/</guid></item><item><title>From Pixel to Pigment: A Decade with Impressionism</title><link>https://www.junyi.dev/en/posts/into-the-modern/</link><pubDate>Wed, 07 Jan 2026 15:16:00 +0800</pubDate><author>junyi.h@comp.nus.edu.sg (Junyi Hou)</author><description>
INTO THE MODERN In early 2026, at the &amp;amp;ldquo;INTO THE MODERN&amp;amp;rdquo; Impressionist exhibition in Singapore, I stood before an original Monet for the first time.
Layers of pigment were stacked high, brushstrokes clearly visible, as if freezing a moment of shimmering dawn or fading dusk from a century ago. At that moment, my mind drifted back to a quiet Sunday exactly ten years prior (2016). I was sitting in front of a computer, flipping through images on the screen one by one, selecting paintings to present for my art class. The theme was &amp;amp;ldquo;From Tradition to Modernity.&amp;amp;rdquo; At that time, the word &amp;amp;ldquo;Impressionism&amp;amp;rdquo; was still foreign to me, and I could never have imagined that one day I would come face to face with these masterpieces that had once only existed for me as pixels.</description><content:encoded>&lt;div style="display: flex; gap: 1rem; flex-wrap: wrap; justify-content: center;"&gt;
&lt;figure style="flex: 1; min-width: 280px; max-width: 280px; margin: 0;"&gt;
&lt;img src="https://www.junyi.dev/posts/into-the-modern/IMG_1797.jpg" alt="into-the-modern" style="width: 280px; border-radius: 10px;" /&gt;
&lt;figcaption style="text-align: center;"&gt;INTO THE MODERN&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/div&gt;
&lt;p&gt;In early 2026, at the &amp;ldquo;INTO THE MODERN&amp;rdquo; Impressionist exhibition in Singapore, I stood before an original Monet for the first time.&lt;/p&gt;
&lt;p&gt;Layers of pigment were stacked high, brushstrokes clearly visible, as if freezing a moment of shimmering dawn or fading dusk from a century ago. At that moment, my mind drifted back to a quiet Sunday exactly ten years prior (2016). I was sitting in front of a computer, flipping through images on the screen one by one, selecting paintings to present for my art class. The theme was &amp;ldquo;From Tradition to Modernity.&amp;rdquo; At that time, the word &amp;ldquo;Impressionism&amp;rdquo; was still foreign to me, and I could never have imagined that one day I would come face to face with these masterpieces that had once only existed for me as pixels.&lt;/p&gt;
&lt;p&gt;Although the exhibition did not feature Monet&amp;rsquo;s most famous works, it was unexpectedly comprehensive: Monet, Renoir, Sisley, and Cézanne. These four young men, who once studied together at Gleyre&amp;rsquo;s Studio, were now reunited through their paintings. Monet&amp;rsquo;s work featured paint as thick as relief sculpture; you could clearly see the brushstrokes piled into heavy clumps. Renoir bathed faces in soft light, luminous yet hazy. Sisley&amp;rsquo;s landscapes were tranquil and restrained. As for Cézanne, I didn&amp;rsquo;t have much of an impression of him&amp;hellip;&lt;/p&gt;
&lt;div style="display: flex; gap: 1rem; flex-wrap: wrap;"&gt;
&lt;figure style="flex: 1; min-width: 280px; margin: 0;"&gt;
&lt;img src="https://www.junyi.dev/posts/into-the-modern/IMG_1785.jpg" alt="haystacks" style="width: 280px; border-radius: 10px;" /&gt;
&lt;figcaption&gt;&lt;em&gt;Haystacks&lt;/em&gt;, 1890 - Claude Monet&lt;br/&gt;(Paint as thick as relief sculpture; brushstrokes clearly visible in heavy clumps)&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/div&gt;
&lt;p&gt;The biggest surprise of the exhibition for me was Paul Signac. While his colors retained the flavor of Impressionism, his technique was entirely different. There were no sweeping brushstrokes on the canvas, replaced instead by countless, meticulous tiny dots of color. Later, I learned that Paul Signac was a representative painter of &amp;ldquo;&lt;a href="https://en.wikipedia.org/wiki/Pointillism" target="_blank" rel="noopener noreferrer"&gt;Pointillism&lt;/a&gt;.&amp;rdquo;&lt;/p&gt;
&lt;div style="display: flex; gap: 1rem; flex-wrap: wrap;"&gt;
&lt;figure style="flex: 1; min-width: 280px; margin: 0;"&gt;
&lt;img src="https://www.junyi.dev/posts/into-the-modern/IMG_1763.jpg" alt="View of the Seine at Herblay 1889, Paul Signac" style="width: 280px; border-radius: 10px;" /&gt;
&lt;figcaption&gt;&lt;em&gt;View of the Seine at Herblay&lt;/em&gt;, 1889 - Paul Signac&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure style="flex: 1; min-width: 280px; margin: 0;"&gt;
&lt;img src="https://www.junyi.dev/posts/into-the-modern/IMG_1759.jpg" alt="IMG_1759" style="width: 280px; border-radius: 10px;" /&gt;
&lt;figcaption&gt;&lt;em&gt;Port of Saint-Cast&lt;/em&gt;, 1889 - Paul Signac&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/div&gt;
&lt;p&gt;Standing in the gallery, my memory flowed back to that high school student trying to explain &amp;ldquo;From Tradition to Modernity&amp;rdquo; in a PowerPoint presentation. Back then, I understood these works solely through Wikipedia and a few images, easily applying a label to each movement:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Neoclassicism&lt;/strong&gt; pursued rigorous composition and scientific perspective, but later fell into empty, dogmatic forms;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Romanticism&lt;/strong&gt; sought the sublime in mythology and passion, eventually sliding into emotional exaggeration;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Realism&lt;/strong&gt; turned its gaze to the common people and daily life, yet failed to break through the framework of traditional narrative;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Impressionism&lt;/strong&gt; broke free from the studio to capture fleeting light and color, yet in its obsession with visual impressions, almost forgot the structure of form;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Post-Impressionism&lt;/strong&gt; turned completely inward: Van Gogh burned with inner fire, Gauguin fled to symbolic shores. They no longer sought &amp;ldquo;how the world looks,&amp;rdquo; but turned to &amp;ldquo;how I feel the world.&amp;rdquo;&lt;/li&gt;
&lt;li&gt;&amp;hellip;&amp;hellip;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The exploration of beauty never ceases. Ten years have passed, and I am no longer just reading a smoothed-over version of history across a screen. Now, standing before the originals, I truly felt that chaotic, intense, and uncertain process of creation. Beyond the shock of the color and brushwork, there was a deep historical resonance. &lt;strong&gt;I was not only conversing with the artists, but I was also conversing with myself.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The modern path of art is inherently a constant fission and recombination between reason and sensibility, order and freedom.&lt;/p&gt;
&lt;p&gt;Behind the canvases are living, breathing people. Amidst doubt and loneliness, they still chose to believe their own eyes.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;“There really is no such thing as Art. There are only artists.”&lt;br&gt;
&amp;mdash; E. H. Gombrich, &lt;em&gt;The Story of Art&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;</content:encoded><category>Personal</category><category>Life</category><guid isPermaLink="true">https://www.junyi.dev/en/posts/into-the-modern/</guid></item><item><title>From Raspberry Pi to NUC: An Upgrade of My Toy</title><link>https://www.junyi.dev/en/posts/intel-nuc/</link><pubDate>Fri, 05 Sep 2025 13:07:09 +0800</pubDate><author>junyi.h@comp.nus.edu.sg (Junyi Hou)</author><description>
My long‑serving Raspberry Pi 4B has finally met its “successor” — the compact Intel NUC. From ARM to x86, from a TF card to an SSD, this seemingly simple hardware upgrade has brought a qualitative leap to my tinkering experience.</description><content:encoded>&lt;p&gt;My long‑serving Raspberry Pi 4B has finally met its “successor” — the compact Intel NUC. From ARM to x86, from a TF card to an SSD, this seemingly simple hardware upgrade has brought a qualitative leap to my tinkering experience.&lt;/p&gt;
&lt;h2 id="raspberry-pi-pros-and-cons" &gt;
&lt;div&gt;
&lt;a href="#raspberry-pi-pros-and-cons"&gt;
#
&lt;/a&gt;
Raspberry Pi: Pros and Cons
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;The Raspberry Pi 4B used to be a key pillar of my digital life. It quietly ran various lightweight services: monitoring my home network traffic, running automation scripts, and even serving as a temporary dev/test environment. To keep it stable, I set up a cron job to automatically reboot every Sunday, which kept it fairly reliable most of the time.&lt;/p&gt;
&lt;p&gt;But its shortcomings surfaced as I pushed it further. What worried me most was the reliability of TF‑card storage — even with so‑called “endurance” cards, you can’t fully avoid I/O risks. After I published the article on &lt;a href="https://www.junyi.dev/posts/k3s-101/" target="_blank" rel="noopener noreferrer"&gt;setting up k3s on a Raspberry Pi 5 cluster&lt;/a&gt;, my four‑node cluster ran stably for only a week before one node died due to a sudden flood of I/O errors. Ironically, that node was using a carefully chosen high‑endurance card, which ended up lasting even shorter than the ordinary card in the 4B.&lt;/p&gt;
&lt;p&gt;Performance bottlenecks also became more evident under heavier workloads: compiling a slightly larger project meant a long progress‑bar crawl, and occasional stutters with multiple services running in parallel reminded me of the hardware’s limits. While attaching an external SSD can ease the storage issue, it undoubtedly increases complexity; at that point, it made more sense to upgrade to a more reliable platform.&lt;/p&gt;
&lt;h2 id="unboxing-the-nuc-and-the-performance-leap" &gt;
&lt;div&gt;
&lt;a href="#unboxing-the-nuc-and-the-performance-leap"&gt;
#
&lt;/a&gt;
Unboxing the NUC and the Performance Leap
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;Unboxing the NUC, my first impression was: “too refined to be a mini PC.” The palm‑sized body isn’t much bigger than a Pi, but the hefty metal shell feels solid. I installed &lt;a href="https://ubuntu.com/download/desktop" target="_blank" rel="noopener noreferrer"&gt;Ubuntu Desktop 24.04&lt;/a&gt;. The performance gap is downright comforting: a Go project that took 3 minutes to compile on the Pi finishes in just 1 minute on the NUC.&lt;/p&gt;
&lt;figure&gt;
&lt;img id="fig1" src="https://www.junyi.dev/posts/intel-nuc/nuc1.jpg" alt="The Intel NUC" max-width="500px" style="border-radius: 10px; box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.3);" /&gt;
&lt;figcaption&gt;Fig.1 - Compact yet powerful Intel NUC&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;The NUC brings not just performance but peace of mind: the built‑in SSD eliminates the TF‑card read/write anxiety, and the extra memory plus x86 architecture make heavier tasks easy. I finally feel comfortable running a small database locally — I used to worry frequent writes would burn out the card.&lt;/p&gt;
&lt;p&gt;As for the retired Raspberry Pis, they’ll pivot to edge‑computing nodes and keep contributing.&lt;/p&gt;
&lt;h2 id="closing-thoughts" &gt;
&lt;div&gt;
&lt;a href="#closing-thoughts"&gt;
#
&lt;/a&gt;
Closing Thoughts
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;Whenever I see the Pi’s LEDs blinking in rhythm, I remember the excitement of first unboxing it. The joy of tinkering was never about chasing the newest and the fastest, but about watching these devices find the right place at each stage — like tools slowly collecting stories of their own.&lt;/p&gt;</content:encoded><category>Linux</category><category>Tech</category><guid isPermaLink="true">https://www.junyi.dev/en/posts/intel-nuc/</guid></item><item><title>MacBook Pro M1 Keycap Replacement Guide</title><link>https://www.junyi.dev/en/posts/replace-keyboard/</link><pubDate>Fri, 01 Aug 2025 01:16:29 +0800</pubDate><author>junyi.h@comp.nus.edu.sg (Junyi Hou)</author><description>
As we all know, once keycaps get greasy and shiny (“oiled”), it&amp;amp;rsquo;s almost impossible to restore them by simply wiping. I once tried sanding the keycaps, but not only did it make things worse, the plastic debris also fell into the gaps between the keys. (You can see my sanded Shift and Space keys in the photo — they look terrible.)
While some say you can go to an Apple Store to get them replaced1, it&amp;amp;rsquo;s said they only allow a few keycaps to be replaced each time. For someone like me whose entire keyboard is shiny, DIY replacement is clearly the more practical option.
I&amp;amp;rsquo;m writing this blog post because I found it incredibly difficult to locate a complete guide on how to replace MacBook Pro M1 keycaps. Most of the videos online2 only show how to remove regular letter and number keys — they deliberately skip over the longer keys like Space, Caps Lock, ESC, and the arrow keys. These keys have different internal structures and require a different disassembly approach.</description><content:encoded>&lt;p&gt;As we all know, once keycaps get greasy and shiny (“oiled”), it&amp;rsquo;s almost impossible to restore them by simply wiping. I once tried sanding the keycaps, but not only did it make things worse, the plastic debris also fell into the gaps between the keys. (You can see my sanded &lt;kbd&gt;Shift&lt;/kbd&gt; and &lt;kbd&gt;Space&lt;/kbd&gt; keys in the photo — they look terrible.)&lt;/p&gt;
&lt;p&gt;While some say you can go to an Apple Store to get them replaced&lt;sup id="fnref:1"&gt;&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref"&gt;1&lt;/a&gt;&lt;/sup&gt;, it&amp;rsquo;s said they only allow a few keycaps to be replaced each time. For someone like me whose &lt;strong&gt;entire keyboard is shiny&lt;/strong&gt;, DIY replacement is clearly the more practical option.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;m writing this blog post because I found it incredibly difficult to locate a complete guide on how to replace MacBook Pro M1 keycaps. Most of the videos online&lt;sup id="fnref:2"&gt;&lt;a href="#fn:2" class="footnote-ref" role="doc-noteref"&gt;2&lt;/a&gt;&lt;/sup&gt; only show how to remove regular letter and number keys — they &lt;strong&gt;deliberately skip over&lt;/strong&gt; the longer keys like &lt;kbd&gt;Space&lt;/kbd&gt;, &lt;kbd&gt;Caps Lock&lt;/kbd&gt;, &lt;kbd&gt;ESC&lt;/kbd&gt;, and the arrow keys. These keys have different internal structures and require a different disassembly approach.&lt;/p&gt;
&lt;figure&gt;
&lt;img src="https://www.junyi.dev/posts/replace-keyboard/beforeafter.png" style="border-radius:0.5rem; width: 1024px"&gt;
&lt;figcaption&gt;
Keyboard before (top) and after (bottom) the keycap replacement
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;I bought a full set of replacement keycaps from Taobao for 85 RMB ($11.81 USD) and decided to do it myself:&lt;/p&gt;
&lt;figure&gt;
&lt;img src="https://www.junyi.dev/posts/replace-keyboard/taobao.jpeg" style="border-radius:0.5rem; width: 256px"&gt;
&lt;figcaption&gt;
The full set of keycaps purchased from Taobao
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;hr&gt;
&lt;h2 id="removing-the-old-keycaps" &gt;
&lt;div&gt;
&lt;a href="#removing-the-old-keycaps"&gt;
#
&lt;/a&gt;
Removing the Old Keycaps
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;The MacBook Pro uses a scissor-switch keyboard mechanism, so we need to follow the correct disassembly procedure for scissor-switch keys.&lt;/p&gt;
&lt;h3 id="regular-key-disassembly" &gt;
&lt;div&gt;
&lt;a href="#regular-key-disassembly"&gt;
##
&lt;/a&gt;
Regular Key Disassembly
&lt;/div&gt;
&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Steps:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Locate the latches&lt;/strong&gt;: Each regular keycap has four clips, one at each corner.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Start at the bottom right&lt;/strong&gt;: Gently lift the bottom-right corner with a spudger. A soft “pop” sound means the latch has been released.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Then the bottom left&lt;/strong&gt;: Do the same for the bottom-left corner.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lift the keycap&lt;/strong&gt;: Once the bottom two clips are released, gently lift the entire keycap upward.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fully remove&lt;/strong&gt;: The top clips will naturally detach.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Tips:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;⚠️ Avoid applying excessive force — the scissor-switch mechanism is fragile.&lt;/li&gt;
&lt;li&gt;⚠️ Never pull straight up — it may break the clips.&lt;/li&gt;
&lt;li&gt;💡 Use a plastic spudger instead of metal tools to prevent scratches.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You can refer to this video&lt;sup id="fnref1:2"&gt;&lt;a href="#fn:2" class="footnote-ref" role="doc-noteref"&gt;2&lt;/a&gt;&lt;/sup&gt; — it clearly demonstrates how to remove regular keys.&lt;/p&gt;
&lt;p&gt;To reinstall the keycap, reverse the removal steps: slide the keycap into the top clips first, then press down until it snaps into place.&lt;/p&gt;
&lt;p&gt;⚠️ Be careful not to press too hard — the plastic components are delicate.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id="long-key-disassembly-eg-shift-caps-lock-esc" &gt;
&lt;div&gt;
&lt;a href="#long-key-disassembly-eg-shift-caps-lock-esc"&gt;
##
&lt;/a&gt;
Long Key Disassembly (e.g. Shift, Caps Lock, ESC)
&lt;/div&gt;
&lt;/h3&gt;
&lt;p&gt;These larger keys usually contain one or two stabilizer wires (metal bars), making the structure more complex.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Removal Tips:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Start from the bottom-right corner, then move to the bottom-left.&lt;/li&gt;
&lt;li&gt;Do not use excessive force — &lt;strong&gt;avoid bending the metal stabilizer sockets&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;figure&gt;
&lt;img src="https://www.junyi.dev/posts/replace-keyboard/capslock.jpg" style="border-radius:0.5rem; width: 512px"&gt;
&lt;figcaption&gt;
Removed Caps Lock key (pointed to by tweezers) and the longer Shift key
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;&lt;strong&gt;Installation Tips:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;For a single stabilizer wire&lt;/strong&gt;:&lt;br&gt;
Attach the metal bar to the keycap first, slide it into the slot on the keyboard, then press down to install as usual.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;For two stabilizer wires&lt;/strong&gt;:&lt;br&gt;
Mount the &lt;strong&gt;top stabilizer wire to the keycap&lt;/strong&gt;, and &lt;strong&gt;clip the bottom stabilizer wire into the keyboard&lt;/strong&gt;.&lt;br&gt;
First align the keycap with the top clips and press gently, then push the bottom part into place.&lt;/li&gt;
&lt;/ul&gt;
&lt;figure&gt;
&lt;img src="https://www.junyi.dev/posts/replace-keyboard/longkey.jpg" style="border-radius:0.5rem; width: 512px"&gt;
&lt;figcaption&gt;
Keycap with a single stabilizer wire: first secure the wire, then press the keycap into place
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;hr&gt;
&lt;h3 id="short-key-disassembly-arrow-keys" &gt;
&lt;div&gt;
&lt;a href="#short-key-disassembly-arrow-keys"&gt;
##
&lt;/a&gt;
Short Key Disassembly (Arrow Keys)
&lt;/div&gt;
&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Up Arrow&lt;/strong&gt;: First release the left-side clip, then &lt;strong&gt;slide the key to the right&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Left/Down/Right Arrows&lt;/strong&gt;: First release the right-side clip, then &lt;strong&gt;slide the key to the left&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;(Before attempting this, refer to the structural images below.)&lt;/p&gt;
&lt;figure&gt;
&lt;img src="https://www.junyi.dev/posts/replace-keyboard/arrowkeys.jpg" style="border-radius:0.5rem; width: 512px"&gt;
&lt;img src="https://www.junyi.dev/posts/replace-keyboard/arrowkey_side.jpg" style="border-radius:0.5rem; width: 512px"&gt;
&lt;img src="https://www.junyi.dev/posts/replace-keyboard/arrowkey_top.jpg" style="border-radius:0.5rem; width: 512px"&gt;
&lt;figcaption&gt;
Arrow key structure diagram
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;hr&gt;
&lt;h2 id="summary" &gt;
&lt;div&gt;
&lt;a href="#summary"&gt;
#
&lt;/a&gt;
Summary
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;Be sure to check &lt;strong&gt;all removed keycaps&lt;/strong&gt; before finishing — I realized I had left two stabilizer wires behind only when I was nearly done.&lt;/p&gt;
&lt;figure&gt;
&lt;img src="https://www.junyi.dev/posts/replace-keyboard/final.jpg" style="border-radius:0.5rem; width: 512px"&gt;
&lt;figcaption&gt;
All the old keycaps after removal
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;hr&gt;
&lt;h3 id="troubleshooting" &gt;
&lt;div&gt;
&lt;a href="#troubleshooting"&gt;
##
&lt;/a&gt;
Troubleshooting
&lt;/div&gt;
&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Key doesn’t feel right after reinstallation?&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Check if the stabilizer wire is properly clipped in.&lt;/li&gt;
&lt;li&gt;Ensure the scissor switch isn’t bent or broken.&lt;/li&gt;
&lt;li&gt;Remove the keycap and reinstall in the correct order.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Keycap easily falls off?&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Make sure all four clips are secured.&lt;/li&gt;
&lt;li&gt;Verify the stabilizer is installed in the correct direction.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Key feels stuck or unresponsive?&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Check for dust or debris underneath the cap.&lt;/li&gt;
&lt;li&gt;Ensure the scissor switch is properly aligned.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id="pro-tips" &gt;
&lt;div&gt;
&lt;a href="#pro-tips"&gt;
##
&lt;/a&gt;
Pro Tips
&lt;/div&gt;
&lt;/h3&gt;
&lt;p&gt;If you&amp;rsquo;re unsure about your dexterity or notice any broken clips or switch parts, it&amp;rsquo;s best to:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Contact Apple’s official repair service;&lt;/li&gt;
&lt;li&gt;Find a trustworthy third-party repair shop;&lt;/li&gt;
&lt;li&gt;Watch more tutorial videos and practice before diving in.&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="footnotes" role="doc-endnotes"&gt;
&lt;hr&gt;
&lt;ol&gt;
&lt;li id="fn:1"&gt;
&lt;p&gt;&lt;a href="https://discussionschinese.apple.com/thread/252109035?sortBy=rank" target="_blank" rel="noopener noreferrer"&gt;What to do when MacBook Pro keycaps get shiny — can Apple Stores replace them for free?&lt;/a&gt;&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id="fn:2"&gt;
&lt;p&gt;&lt;a href="https://www.bilibili.com/video/BV1UR4y1E7be/" target="_blank" rel="noopener noreferrer"&gt;Bilibili: MacBook Scissor Key Keyboard Disassembly (Keycaps + Scissor Switch)&lt;/a&gt;&amp;#160;&lt;a href="#fnref:2" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&amp;#160;&lt;a href="#fnref1:2" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;</content:encoded><category>TechShare</category><category>Tech</category><guid isPermaLink="true">https://www.junyi.dev/en/posts/replace-keyboard/</guid></item><item><title>Ultra-Low-Voltage Buck–Boost Converter (0.3 V to 5 V)</title><link>https://www.junyi.dev/en/posts/boost-converter/</link><pubDate>Fri, 29 Mar 2024 16:42:00 +0800</pubDate><author>junyi.h@comp.nus.edu.sg (Junyi Hou)</author><description>
Figure 1 – Ultra Boost Converter</description><content:encoded>&lt;figure&gt;
&lt;img style="border-radius:0.5em; box-shadow: 0px 0px 5px rgba(0,0,0,0.2)" src="https://www.junyi.dev/posts/boost-converter/pic1.JPG" alt="Boost Converter by Junyi" width="500" /&gt;
&lt;figcaption&gt;Figure 1 – Ultra Boost Converter&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2 id="chip-selection" &gt;
&lt;div&gt;
&lt;a href="#chip-selection"&gt;
#
&lt;/a&gt;
Chip Selection
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;I took on a challenging requirement: convert 0.5 V to 2.0 V.&lt;/p&gt;
&lt;p&gt;A conventional boost–buck approach couldn’t meet this because a typical diode drop is about 0.7V while the source only provides 0.5V.&lt;/p&gt;
&lt;p&gt;Most converter ICs on the market need around 0.9V to start up, which this source doesn’t meet.&lt;/p&gt;
&lt;p&gt;After quite a bit of searching, I found the &lt;code&gt;TPS61200&lt;/code&gt;, which supports 0.3–5.5V input and can output 1.8–5.5V (adjustable or fixed).&lt;/p&gt;
&lt;h2 id="pcb-design" &gt;
&lt;div&gt;
&lt;a href="#pcb-design"&gt;
#
&lt;/a&gt;
PCB Design
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;Big thanks to my mentor JYF, who gave me lots of guidance and turned my PCB around.&lt;/p&gt;
&lt;p&gt;For the first revision, I followed these app notes: &lt;a href="https://www.ti.com/lit/pdf/slva345" target="_blank" rel="noopener noreferrer"&gt;Supplying TPS61200 With a Single Solar Cell&lt;/a&gt; and &lt;a href="https://www.ti.com/lit/pdf/slva364" target="_blank" rel="noopener noreferrer"&gt;Using TPS61200 as WLED Driver&lt;/a&gt;. JYF taught me many power-supply layout tips, such as:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Use keep-out regions when copper-pouring to avoid creating “antennas”.&lt;/li&gt;
&lt;li&gt;Near any “antenna,” add vias so interference returns cleanly to ground.&lt;/li&gt;
&lt;li&gt;Size the PCB in 5 mm increments to make enclosures easier.&lt;/li&gt;
&lt;li&gt;Stencils are cheaper to order on Taobao.&lt;/li&gt;
&lt;li&gt;&lt;del&gt;Horizontal sockets are awkward; vertical ones work better.&lt;/del&gt;&lt;/li&gt;
&lt;li&gt;&amp;hellip;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;His help was key to making this project succeed on the first try.&lt;/p&gt;
&lt;p&gt;(By the way, I skipped the MPP circuit. That can demand about 1A from the source for up to 20ms, which is effectively a short for some fuel cells and can shorten their lifespan.)&lt;/p&gt;
&lt;h2 id="soldering" &gt;
&lt;div&gt;
&lt;a href="#soldering"&gt;
#
&lt;/a&gt;
Soldering
&lt;/div&gt;
&lt;/h2&gt;
&lt;p&gt;This was my first time hand-soldering a QFN. I ruined one PCB before getting it right, but the final result came out great.&lt;/p&gt;
&lt;p&gt;My 0805 soldering used to look ugly; now 0603 is no problem either. &lt;del&gt;If I ever get laid off, I can go tighten screws in a factory.&lt;/del&gt;&lt;/p&gt;
&lt;p&gt;Compared with before, I made a lot of progress this round (see my earlier board here: &lt;a href="https://oshwhub.com/osdubv/myfirstespproject_copy" target="_blank" rel="noopener noreferrer"&gt;Ultra‑mini ESP8266 dev board&lt;/a&gt;).&lt;/p&gt;
&lt;h2 id="photos" &gt;
&lt;div&gt;
&lt;a href="#photos"&gt;
#
&lt;/a&gt;
Photos
&lt;/div&gt;
&lt;/h2&gt;
&lt;figure&gt;
&lt;div style="display: flex"&gt;
&lt;img src="https://www.junyi.dev/posts/boost-converter/view1.jpg" alt="Boost Converter by Junyi" width="50%" /&gt;
&lt;img src="https://www.junyi.dev/posts/boost-converter/view2.jpg" alt="Boost Converter by Junyi" width="50%" /&gt;
&lt;/div&gt;
&lt;figcaption&gt;Figure 2 – Ultra Boost Converter&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure&gt;
&lt;div style="display: flex"&gt;
&lt;div style="width:50%"&gt;
&lt;img src="https://www.junyi.dev/posts/boost-converter/onboard_LED.jpg" alt="Boost Converter by 3.0V battery" /&gt;
&lt;figcaption&gt;(a) Using a 3.0V coin cell to light the 2.0V LED&lt;/figcaption&gt;
&lt;/div&gt;
&lt;div style="width:50%"&gt;
&lt;img src="https://www.junyi.dev/posts/boost-converter/by_battery.jpg" alt="Boost Converter by 1.5V battery"/&gt;
&lt;figcaption&gt;(b) Using a 1.5V battery to light the 2.0V LED&lt;/figcaption&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;figcaption&gt;Figure 3 – 2.0V regulated output lighting the LED from different inputs&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure&gt;
&lt;div style="display: flex"&gt;
&lt;div style="width:50%"&gt;
&lt;img src="https://www.junyi.dev/posts/boost-converter/new_version.jpg" alt="New Boost Converter" /&gt;
&lt;!-- &lt;figcaption&gt;(a) 使用 3.0V 纽扣电池&lt;/figcaption&gt; --&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;p style="padding-left:1em"&gt;&lt;strong&gt;Update on 2024‑06‑15:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Removed the pin header&lt;/li&gt;
&lt;li&gt;Removed the coin‑cell holder&lt;/li&gt;
&lt;li&gt;Removed the POWERSAVE option (enabled by default)&lt;/li&gt;
&lt;li&gt;Improved adjustable output range from 0.5–3.3V to 0.5–5.5V&lt;/li&gt;
&lt;li&gt;Switched to a 3mm potentiometer; shorter feedback path&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;figcaption&gt;Figure 4 – Updated Ultra Boost Converter (Top), Original (Bottom)&lt;/figcaption&gt;
&lt;/figure&gt;</content:encoded><category>Projects</category><guid isPermaLink="true">https://www.junyi.dev/en/posts/boost-converter/</guid></item></channel></rss>