Latest Posts (20 found)
Unsung Today

“…and at that point it stops being a system.”

A few nice moments that caught my attention in Marek Minor’s case study of designing icons for Cursor . The diagonals for “cancel/​wrong” icons are always backslashes, and all the “positive” icons are always slashes: There is a system that provides 1-to-1-and-only-1 mapping between a concept and an icon: The point of the table is that each “What’s the icon for [X]?” has exactly one answer, and keeps having exactly one answer as the product grows. Without it, a set slowly develops two icons for the same idea, and at that point it stops being a system. = 2x) and (width >= 700px)" srcset="https://unsung.aresluna.org/_media/and-at-that-point-it-stops-being-a-system/2.2096w.avif" type="image/avif"> = 3x) or (width >= 700px)" srcset="https://unsung.aresluna.org/_media/and-at-that-point-it-stops-being-a-system/2.1600w.avif" type="image/avif"> And, on the page itself, a few nice blink-comparator -like vehicles with subtle text animations: #case study #iconography #system design

0 views
Unsung Today

Ivory’s account switching

Ivory is a Mastodon client, and their account switcher has a few interesting mechanics. The standard one is that you can tap on your avatar, and get a menu in response: But you can also drag down on the avatar, and get a different reaction: This, I believe, is meant to be a slightly faster way. The settings option is gone to simplify, and the whole thing looks and feels more… gestural, in lack of a better word. But I think it also serves one more purpose. The moment I saw this, I thought to myself “I wonder if I could just swipe on the icon itself?” and, lo and behold, this is actually possible: Why does it matter? I think for some power users of social media – perhaps people doing it professionally – you switch accounts all the time, and investing in this interaction being fast and smooth is important. This whole small interaction system feels similar to switching apps on a Mac. You can choose an app in your dock with a mouse (the slow, but well-lit way). You can then learn to use ⌘⇥ and hold ⌘ to get to it quicker from a temporary menu. Eventually, you will also start tapping ⌘⇥ quickly, skipping any visible UI surface altogether. There is also something great in seeing an interface that grows with you, or one where you can say “I wonder if…” based on your prior interactions and expectations, and the interface actually rewarding you for that thought. #flow #onboarding #system design #touch

0 views

Skip the GPU Upgrade: Repurpose a Gaming PC or Mac for Immich's ML Queue

My Immich server runs on an older Dell box that never had much of a GPU to speak of, just enough for transcoding Jellyfin. Photos upload fine. Thumbnails generate fine. The moment the machine learning jobs kick in, the queue backs up and stays backed up. Face detection, smart search embeddings, OCR, all of it running on a CPU that was never built for the job. I'd been chipping away at the queue for months. A few hundred photos would process overnight, then I'd add a few thousand more from an old backup drive and watch the counter barely move. At that rate I was going to be waiting months to get a fully searchable library. Immich has a built-in answer for underpowered hardware. You can point the machine learning container at a separate, more capable machine and let the server talk to it over the network instead of processing locally. It's called remote machine learning , and the use case is right there in the docs: pair a weak NAS with whatever stronger box happens to be sitting on the same network. I had two candidates sitting around. A gaming PC with an NVIDIA card that mostly sits idle outside of a few nights a week, and my M4 MacBook Pro with 24GB of RAM. The Mac won mostly because it's quieter and I didn't need to keep a tower running just to burn through a backlog. Either one would have worked. The point of remote machine learning is that Immich doesn't care what's on the other end, as long as it can serve requests. Docker on Apple Silicon can't reach the GPU. That's not an Immich limitation, it's a Docker Desktop and container runtime limitation on macOS in general. Metal, the Neural Engine, CoreML, none of it is exposed to a container. So if you spin up Immich's standard immich-machine-learning image on a Mac, you get CPU-only inference no matter how fast the chip is. People have been asking about this in the Immich GitHub discussions for a couple of years now. The maintainers were straightforward about it: hardware acceleration can't be passed through to a container on ARM Mac hardware. If you want the GPU, you have to run the ML service natively, outside Docker entirely. My first attempt at remote machine learning was the plain version straight from the Immich docs. Stand up the immich-machine-learning container on the Mac, point the server at it, done. It worked, technically. It was also barely faster than running the queue locally on the Dell box, because that container ships a Python and ONNX Runtime stack built for x86 with optional CUDA, not for Apple Silicon. On a Mac it just falls back to plain CPU inference inside the container, the same bottleneck I was trying to get away from. Getting past that meant stepping outside Docker entirely and building a native ML service that could reach Metal and the Neural Engine directly. That's a real project on its own: cloning Immich's machine learning code, swapping in CoreML execution providers, installing Poetry, wiring up a separate model cache, and keeping all of it in sync every time Immich itself gets updated. A few people in the Immich discussions had documented doing exactly this by hand, and it's not a small undertaking to maintain solo. A project called Immich Accelerator does that work for you. It's a Homebrew-installed tool that extracts Immich's own microservices worker directly from your running Docker image, then runs it natively on macOS alongside a separate ML service built on Apple's frameworks. The split looks like this. Docker keeps the lightweight pieces: the API server, Postgres, Redis. All of that stays wherever it already lives, in my case on Unraid. The Mac runs the parts that chew through CPU or GPU cycles. CLIP embeddings for smart search run on the Metal GPU through MLX. Face detection and OCR run on the Neural Engine through Apple's Vision framework. Face recognition uses ONNX with CoreML acceleration layered on top. Video transcoding gets remapped from software encoding to VideoToolbox hardware encoding. None of this touches the Docker image itself. The tool doesn't patch Immich or rebuild anything, it just extracts the worker code that's already sitting in the container you're running, so it always matches your version exactly. Mount the media share first, before running setup, so the Mac sees the same path your Docker host uses. I'm mounting the same SMB share Unraid exports: Then install the accelerator and point it at the remote server: There's one hard requirement for this split setup, and it's the one that trips people up. Both machines need to see the exact same media files at the exact same absolute path. Immich stores paths like in Postgres, and if the Mac's mount point doesn't match, thumbnails will 404 even though the worker itself is happily processing jobs. Run it as a background service rather than a one-off foreground process, so it survives reboots and picks up Immich version updates on its own: A few commands come in handy once it's running. Check that everything came up healthy: Confirm the ML service can actually reach Metal and the Neural Engine, not just that it's alive: Tail the logs if a job gets stuck: Watch throughput without opening the Immich admin panel through the built-in dashboard on port 8420: Open from any device on the network, including your phone, to watch the queue drain in real time. If you ever need to back out, stop the service and remove the environment variables and exposed ports from your Docker host's compose file: Once it's running, the per-queue concurrency settings in Immich's admin panel matter more than they do with a single Docker ML container. GPU work and Neural Engine work don't scale the same way CPU work does. Smart search, which runs on the Metal GPU, doesn't benefit from more than two concurrent jobs since MLX serializes GPU access anyway. Face detection and OCR, both running on the Neural Engine, handle three concurrent jobs reasonably well. Thumbnail generation and metadata extraction are CPU or I/O bound and scale higher, up to four. Cranking everything to the same high number just causes CPU thrashing without moving the needle on throughput. The worker itself runs Immich's unmodified code, so the differences are almost entirely in the ML layer. CLIP search results are close but not identical to what Docker's ONNX Runtime path produces, since the underlying computation runs through MLX instead. A search that returns twenty results in Docker might return eighteen or nineteen of the same twenty, occasionally in a different order. Face grouping can land slightly differently at the edges too, since Apple's Vision framework is a different model from the one ONNX Runtime uses. It hasn't caused a real problem for my library. The photos still get found, the faces still get grouped, and the jobs finish in a fraction of the time they used to take. If you've got a Windows or Linux box with a real NVIDIA card instead of a Mac, you actually have it easier. Docker on those platforms can pass GPU access straight through to a container, no native workaround required. The standard remote machine learning setup handles it with a single tag change and a small hardware acceleration file. On the remote machine, the compose file looks like this: Pull the matching from Immich's repo, point your main server's at that machine's address on port 3003, and the queue drains at full GPU speed with none of the native extraction steps a Mac needs. AMD boxes get a similar path through the ROCm tag, and Intel Arc or integrated graphics can use OpenVINO. The Mac route exists because Apple Silicon can't take the easy path. If you're choosing which spare machine in the house gets pulled into service, that's worth weighing. A Windows or Linux box with a GPU is plug and play. A Mac gets you there too, it just takes one extra piece of software to unlock what the hardware can already do. Immich only documents this for Linux and Windows through WSL2, not Docker Desktop's Hyper-V backend directly, so make sure Docker is running through WSL2 before starting. Intel doesn't need a separate link. OpenVINO setup for Arc and integrated graphics is covered on the same hardware acceleration page above, including the WSL2-specific device mapping for and . The heavy lift here is almost entirely a backlog problem. Once a library is caught up, new photos trickle in a handful at a time and even a weak CPU keeps pace fine. The remote machine doesn't need to stay online forever, just long enough to chew through whatever's stacked up in the queue. That makes it a good candidate for batching. Fire up the accelerator or the remote ML container when you know you're about to dump a few thousand photos in, whether that's after digitizing an old hard drive or importing a family member's entire library. Let it run overnight or over a weekend, then shut it down until the next big batch shows up. This matters even more if your GPU is already busy doing something else. A lot of homelab GPUs spend their days on Jellyfin or Plex transcoding, and running Immich's ML jobs on the same card at the same time means the two are fighting over VRAM and compute. Adding a second machine, even a laptop that's only free in the evenings, keeps transcoding and photo processing from stepping on each other and gets a big backlog done in a single dedicated push instead of dragging it out over weeks of contention. The trickiest part of batching is remembering to do it. Nobody thinks about their Immich queue until they get back from vacation and dump four thousand photos into the library at once. Home Assistant can keep an eye on that so I don't have to. I had Claude Code wire this up instead of hand typing it myself, and walking through what went wrong along the way is more useful than just handing over the finished YAML. Nothing here was exotic, but almost every shortcut from an older tutorial needed a second look before it worked. First, confirm what the API returns before writing a single sensor. Immich's queue endpoint lives at : On my version the response nests a count under for each of eighteen job types, smart search, face detection, metadata extraction, and a bunch of others I don't care about for this. The field names happened to match what you'd guess. That's luck, not a guarantee. Run the curl yourself instead of trusting a blog post's field names to still match your version. The REST sensor config has a trap built into it. A lot of older tutorials nest sensors under a entry inside the block. That style validates clean under and reports success on reload, then silently creates zero entities. Modern Home Assistant wants the sensors nested under a top-level key instead: The API key needs permission. If you've already got an Immich integration running in Home Assistant for photo or storage sensors, reuse that key instead of minting a new one, just make sure it lands in cleanly. A key appended by a script inherited a couple of stray trailing spaces from the end of the file once, which indented it one level and quietly broke the top-level mapping. Re-read the file after any scripted edit. Don't just trust that the write succeeded. A template sensor rolls the three queues into one backlog number, which is easier to reason about than three separate ones: Notifications are the other place where old habits bite. A lot of tutorials still call directly. Recent Home Assistant versions moved to an entity-based notify architecture, so the call is with a pointing at the specific notify entity instead. Check which notify entities actually exist and which one carries a real last-sent timestamp before assuming a name. The matters more than it looks like it does. A big backlog appears the instant an upload finishes, and it'll clear itself out over the next hour if the queue is already moving at a normal pace. Requiring the number to stay above the threshold for an hour filters out that normal churn and only pings you when the backlog is actually stuck. Testing that hold is its own small puzzle. A trigger's timer only starts counting from an actual state change, not from a value that was already true when Home Assistant reloaded. Forcing the sensor to a fake threshold doesn't trip it if the queue never moves. The honest way to confirm delivery is to trigger the automation's action directly and check that the notify entity's timestamp actually updates, then set the real threshold back once you know the notification path works. If writing REST sensors by hand isn't appealing, HASS-IMMICH-API is a custom Home Assistant integration that wraps the same job queue endpoints into ready-made sensors and switches, installed through HACS or the SSH addon instead of hand-rolled YAML. The project carries a beta warning, and it means it. Back up your Immich database before the first run. I'd also recommend testing on a smaller library or a subset of your uploads before pointing it at years of accumulated photos, just to get comfortable with how the split deployment behaves on your specific network setup. For a homelab that already has a spare Mac or gaming PC sitting around, or for anyone deciding between buying a GPU for their NAS box versus repurposing hardware that's already on the desk, this closes a gap that's existed in the Immich ecosystem for a while. I thought I was going to have to deal with this some other way, either by throwing money at the problem or by just dealing with it. I'm glad i can use existing hardware to solve the problem for free. Did this help you? I'd love to know. Hardware-Accelerated Machine Learning is the official Immich page covering every backend below, including the exact changes for each one is the file referenced in the compose snippet above. Grab the latest release build rather than an old copy floating around a forum post NVIDIA Container Toolkit install guide walks through getting an NVIDIA card visible inside a container. This is the step most people skip and then wonder why the tag doesn't do anything ROCm Docker install guide covers the AMDGPU driver setup ROCm needs. Its official support targets Linux, so an AMD card on Windows through WSL2 is going to be rougher terrain than the NVIDIA path

0 views

Mount Plauris

I’ve been thinking about this mountain for more than a decade at this point, for reasons I’m gonna explain later. I wanted to hike it a few weeks ago, but there was a raging wildfire not too far from it on a nearby mountain, so I had to wait. Wait for better air conditions, because hiking with ash and dust in the air is not a smart thing to do, but also wait for a colder day. Yesterday wasn’t exactly a cold day, it still got up in the 33° to 35°C range, but we—me and a friend—decided we were going for a hike anyway. We were out of the car at around 8:30 am and thankfully we got blessed with an overcast day which made the hike A LOT more enjoyable. This is one of those quintessential mountain hikes; if you look at the elevation profile, you start walking, you go up, and you keep going up till you basically reach the summit. There were pretty much no flat spots longer than maybe 10 meters, and there was only a brief downhill part because we had to go through a mountain pass before going up again to the summit. We had to gain almost 1500 meters of elevation in roughly 5.5kms and the climb averages at about 26% incline with stretches in the 75-80% range. From a purely physical perspective, this is not an enjoyable walk. The trail starts on a stretch of concrete road, since there is technically a service road that goes up to the nearest mountain hut, but then turns into a lovely trail into the woods and I love walking into the woods. This area would also be a perfect spot to hang a hammock and just chill. Roughly an hour and 500 meters of elevation gained we reached the Elio Franz Hut, a lovely, lovely mountain hut that is incredibly well maintained and clearly well loved by the people here. It’s free to use and very well equipped, and I felt compelled to leave a small donation since there was a donation box inside it, even though we didn’t even stop here. But people who do this type of work deserve to receive some love. This is where the service road ends but the trail keeps going, first through the woods and then it hugs the side of the mountain with a trail that is everything but easy, with slippery rocks, loose rocks, rocks that are falling down, rocks that are falling away below you, sections with steps so high that I found myself with knees in my mouth (and I don’t have short legs). About 2 and a half hours in, we’re at the mountain pass, we have gained about 1100 meters and the summit is in front of us. Thank god is overcast because climbing up this trail with the sun hitting on us would have been absolutely miserable. Down we go through the only tiny downhill stretch of the whole ascent and this is where I stopped taking pictures before getting up to the summit. And that’s because the final stretch is bad. It’s a scree, but not one of those where you have a clear trail marked or those where everything is soft enough where you can just sink your feet in firmly. No, this is probably just the result of rocks that must have recently fallen down. You can’t stick your feet in it, there’s not enough material and the rocks are too big. You can’t properly walk on top of it, because everything is incredibly loose, and you can’t even properly scramble your way across it, because there are stretches where there aren’t even big rocks to use as an anchor. And it’s steep, 50 to 60% grade. So if for some reason you slip, you’re going to slide down quite far and there’s nothing stopping you. So we had to slowly and carefully find our way through this final stretch, trying to figure out the best way to go up this mess, and we eventually did. Boy was this not a fun thing to do. The final bit to the top is a short, fairly exposed trail but that’s easy compared to what we just passed. On the summit, 3 people were enjoying the view. We learned that they came up from the other side, where there’s a much easier and shorter trail. Maybe I’ll do it one day. The view from the top is very enjoyable, the sky full of clouds that are moving at a significant pace. A lovely spot to take a break before starting our descent. From up here, I can clearly see a place that’s stuck in my memory. You see that green-ish dot, down at the bottom of the picture? That’s the roof of another hut I slept in 10 years ago, in August 2016 when with another friend of mine I first attempted hiking this mountain. Back then the plan was to do the first part of the hike, sleep, and the following morning summit the mountain. But I arrived at the hut that I was so tired, so exhausted, that we didn’t go up and for 10 years I kept thinking at this damn mountain. And here I am, 10 years later, at the top, looking at that place from above. And I’m not even tired so take that 27 years old me. We’re getting better with age! After spending almost an hour enjoying our time on the summit, it was time to go down. And again, not many pictures. The trail was so bad that we took almost as much time going down as going up, and that’s not usually the case. But we survived the long, long descent unscathed. I am glad I finally hiked this mountain. I’m glad my adventure of 10 years ago can finally have a proper closure. But I don’t think I’ll ever do this trail again. This was not exactly enjoyable walking. It’s doable, clearly, but definitely not fun. You love the outdoors and RSS. You're one of the special ones.

0 views

Debian Code Search: Fast TurboPFor with Go SIMD

This August, I accomplished what I wanted for many years: I deleted the last cgo dependency in Debian Code Search! This was made possible by Go’s recently introduced SIMD support, because now we can implement the TurboPFor integer compression format as efficiently — more efficiently, in fact, by using the newer AVX512 instruction set! — as the reference implementation. Debian Code Search (DCS) is a search engine that allows searching all the Open Source source code within Debian, with either literal search expressions or regular expression search queries. A search engine uses an inverted index: a map from term to documents containing the term. Each document is typically represented most efficiently by using an id, so the index consists of many lists of document ids. When searching, it is important to quickly decode these lists to answer the search query. However, there is a point of diminishing returns where the decoding speed, even though it can still be measurably improved quite a bit, no longer influences the overall query duration. From 2012 (its inception) to 2019, Debian Code Search used to use a small index format, and queries were fast because the index was kept entirely in RAM. In 2019, I implemented the new index format , which adds an on-disk positional index. For literal queries (78.2% of DCS queries), querying the positional index on disk is faster than querying the non-positional index in RAM. The efficient encoding of the TurboPFor format makes it possible to fit such an index on a mid-sized Hetzner server, which I rent with two 1 TB SSD disks. The optimized decoder of the C TurboPFor library is what made decoding fast at query time. If you want to dive deeper into the algorithm, see this blog post from February 2019: Motivation I have recently been looking into speeding up Debian Code Search. As a quick reminder, search engines answer queries by consulting an inverted index: a map from term to documents containing that term (called a “posting list”). See the Debian Code Search Bachelor Thesis (PDF) for a lot more details. Read more → If you want to learn more about the positional index, see this blog post from September 2019: Over the last few months, I have been developing a new index format for Debian Code Search. This required a lot of careful refactoring, re-implementation, debug tool creation and debugging. Read more → For many years, you had the following options for using SIMD instructions in Go: The C TurboPFor library has served us well, but Debian Code Search was always intended to be a project using Go, so I would prefer it if I did not have any C code in the project. Go 1.26 (released in February 2026) introduced the package: Go 1.26 introduces a new experimental package , which can be enabled by setting the environment variable at build time. This package provides access to architecture-specific SIMD operations. It is currently available on the architecture and supports 128-bit, 256-bit, and 512-bit vector types, such as and , with operations such as . The API is not yet considered stable. — Go 1.26 Release Notes For my 2019 TurboPFor analysis, I implemented , a native Go teaching decoder (without any SIMD), because I find Go code easier to follow than C code, especially optimized C code. My implementation was intentionally not optimized so that the code was easier to study. The TurboPFor format/algorithm has a vector-optimized part: bitpacking comes in a scalar variant ( ) and a vector variant ( ) , where the vector variant is used for full blocks (256 values) and the scalar variant is used for remainder blocks (< 256 values). When Go 1.26 was released, I used Claude Code to explore whether my native Go decoder’s function (for the vertical vector layout) could be implemented using Go SIMD, and the answer was yes, it was possible and it was faster than without SIMD, but not quite at the level of C TurboPFor. If you let Claude Code try for long enough, it eventually finds enough optimizations (about 10) to match C performance. I don’t want to vibe-code Debian Code Search, though, so I figured I would find some time to review the SIMD code at some point and see if I could implement something similar myself. Before I found enough time and motivation to complete said review, I discovered that to not regress real-life query performance by more than 10 to 100 milliseconds (which seems acceptable), I don’t actually need to add SIMD code to my teaching decoder at all; it would be sufficient to reduce allocations in my teaching decoder and specialize it per bit width. Encouraged by the possibility of using the optimized native Go decoder in Debian Code Search, I explored whether I could also implement a native Go encoder so that I could get rid of the C TurboPFor dependency entirely. The answer is yes, it is doable in a few days, and it isn’t even that much slower: Go is at 76% of C, see Debian/dcs commit . The goal I set myself at that point was to see if I could learn enough SIMD to optimize the native Go encoder such that its performance would match how DCS uses C TurboPFor (via cgo). Beating C TurboPFor was possible in 2-3 commits (SIMD and bit width specialization). To my surprise, Claude Fable 5 pointed out that the encoder’s block scanning could be done more efficiently using a technique called positional popcount, and that is another 2x speed-up ! 😲 To be clear: I am not saying the Go compiler beats C here. Certainly, the C compiler can also produce fast AVX512 code and can be used to implement positional popcount. When comparing apples to apples, i.e. backporting the AVX512 kernels and positional popcount technique to C TurboPFor, Go benchmarks a little slower at ≈1.4x C. This spectacular result (much faster than what DCS had before) got me curious how far I could push the decoder with SIMD after all. I ended up matching/exceeding the cgo version here, too! The rest of this article explains a few classes of optimizations I encountered along the way. When I wrote my teaching decoder , I named its functions to match the upstream C TurboPFor library, but now I want to get away from names like — they make sense from the TurboPFor perspective, but for Debian Code Search, we can use cleaner names. Before writing any code, I audited how DCS uses integer compression / decompression. In Debian Code Search, we have the following usage patterns: For reading the index, we do keep the decoded s fully in memory, so we only need , a function that reads values ( ) from and returns how many bytes it consumed. For writing the index (both in partial indexing, and when merging), keeping the entire index in memory is prohibitively expensive, so we need a streaming API, for decoding and for encoding. Ultimately, I converged on the following API: This API (the decoder works similarly) allows us to process data in TurboPFor format without any memory allocations. The types are not safe for concurrent use by multiple goroutines. The zero value is ready to be used. For the streaming API, the result only stays valid until the next call. Before we can optimize anything, we need a working decoder and encoder. The decoder already exists: my teaching decoder. Next up, I needed an encoder. Writing a TurboPFor encoder has a delightfully simple starting point: You can encode all values at bit width 32, in little endian, at which point you only need to add a one-byte TurboPFor block header every 256 values and you’re done: Of course, this is a terribly inefficient compressor, so after the first commit, the real work starts: implement each block type until the compression matches the original C TurboPFor implementation (same output file size), or in other words: do the reverse of the decoder. I found it interesting to realize that the main work of the encoder is to scan the input values and choose the optimal block type, whereas the actual encoding itself is cheap in comparison. At this point, we can look at performance and see that the Go encoder is at 76% of the C encoder. In all honesty, I could have probably stopped here, but now that the milestone of a viable replacement was reached, I got curious to see how far it would be possible to push the encoder (how much work to reach C speeds?) and afterwards, the decoder, too. The microarchitecture of a CPU determines which instructions it provides, and that includes not just SIMD instruction sets (like AVX2), but also other useful instructions like (Leading Zero Count), which can be used to implement more efficiently, which the TurboPFor encoder needs to call on every input value to determine the ideal bit width. Let’s walk through how to set the microarchitecture level when using Go on 64-bit x86 (x86-64). Go uses the environment variable to configure the target compilation architecture, and I am using the value to select 64-bit x86 (AVX2 and AVX512 are instruction sets found on x86-64 CPUs). With , the architecture-specific variable configures the microarchitecture level for which to compile and Go 1.18 introduced these 4 different levels : (default): The baseline. Exclusively generates instructions that all 64-bit x86 processors can execute. : all v1 instructions, plus CMPXCHG16B, LAHF, SAHF, POPCNT, SSE3, SSE4.1, SSE4.2, SSSE3. : all v2 instructions, plus AVX, AVX2 , BMI1, BMI2, F16C, FMA, LZCNT, MOVBE, OSXSAVE. : all v3 instructions, plus AVX512F , AVX512BW, AVX512CD, AVX512DQ, AVX512VL. In 2026, I generally recommend compiling with so that functions like are compiled into intrinsics ( ) instead of using a lookup table. For Intel CPUs, setting means your programs will only start on Haswell CPUs (2013) or newer; for AMD CPUs that means Zen 1 (2017) or newer. In this specific case (DCS), I am even compiling with . The microarchitecture level requires AVX512, which means AMD Zen 4, Zen 5 or newer (Intel’s story is… complicated). Luckily, both my main development PC (Zen 5) and the Debian Code Search server (Zen 4) are recent enough. Setting has little effect on Go 1.27 itself: the only change is that maps use one less instruction ( instead of ). But compiling with allows us to move one more feature check from runtime to compile time, see SIMD build tags . It makes sense to set the microarchitecture level in your benchmark setup so that you don’t measure the slow fallback implementations. I use in my . Go’s built-in package contains support for benchmarks which are written in functions of the form . The simplest way to run such benchmarks is , but I ended up configuring a few convenience targets, which write results to and compare against (the previous commit’s results, usually), using the very useful tool . The and units are custom metrics I am reporting from the various sub-benchmarks , which are arranged such that I can filter / report them with . The main encoder (and decoder) benchmarks compare 3 different implementations (cgo, Go, Go with the StreamEncoder API) with a number of benchmark cases that are designed to cover the different block types and contain a similar mix of values as what we see in Debian Code Search: Go has included excellent performance tooling for many years, see the “Profiling Go Programs” blog post (2011) for an example of how to use , a sampling profiler. This profiler can help track down which part of a program runs slow, or where memory allocations happen. Once you identified the slow part of a program, how do you know why it’s slow? To learn more about the specific bottlenecks your program encounters, you can consult your CPU’s hardware performance counters . For example, you could check the branch predictor counters to see if your program is slow due to a high number of branch mispredicts. On Linux, the tool is the best way to access the CPU hardware performance counters. A good starting point for working with is the documentation on “Top-down analysis with the perf tool” , which describes the optimization method that Intel established. In my , I set up two targets: The numbers are high level numbers that indicate how much work the implementation is doing. Reducing the number usually increases speed. To see the counters for each instruction (and source code lines), I use , followed by . A quick shortcut is , which directly shows the hottest function. Let’s first see how far we can get without reaching for SIMD instructions. (The examples are not necessarily in commit order, but cherry-picked for clarity.) PGO stands for Profile-Guided Optimization and is a feature that Go introduced as a preview in Go 1.20 (released in February 2023) and shipped as ready for general production use in Go 1.21 (released in August 2023). The idea is to capture a CPU profile that records where your program spends most of its CPU time, which you then provide to the Go compiler to give it more data to make better decisions. Most importantly, this way the Go compiler can inline functions much more aggressively than its usual heuristics allow, which does have a measurably positive effect in my series of optimization commits. Another optimization that a PGO profile allows the compiler to do is conditional devirtualization — but our TurboPFor code does not use any interfaces. My strategy is to enable PGO before doing any other optimizations, so that we have the full inlining budget available that PGO gives us, and can measure the effect of other commits clearly. Surprisingly, turning on PGO actually decreases our performance (-13% geomean), but a closer investigation reveals that we just got unlucky. Let me explain. Aside from inlining and conditional devirtualization, PGO also influences alignment: The Go compiler sets on the first block of a loop (the “loop body”) for all loops in hot functions (per the PGO profile), i.e. Go will insert up to 31 bytes of padding to make the block land on a 64-byte boundary. Documentation like AMD’s “Software Optimization Guide for the AMD Zen5 Microarchitecture” (2024, #58455) explicitly recommends aligning hot loops that way: […] for hot loops, some further knowledge of trade-offs can be helpful. Because the processor can read an aligned 64-byte fetch block every cycle, it is suggested to either align the start of the loop to the beginning of a 64-byte cache line […] Indeed, when compiling with to disable the alignment, performance remains as good as without PGO. How can the padding hurt more than help? The answer is: It’s not the padding itself! It’s a side-effect of the padding moving instructions to different addresses. In the unlucky arrangement, a macro-fused + instruction pair now ends up exactly on a 32-byte boundary . However, the Go compiler ensures fused branch sequences must never cross or end at a 32-byte boundary to fix Intel erratum SKX102 (discussion: Go issue #35881 ) by inserting s. This padding, unlike the loop alignment padding, is not free; these extra instructions slow down our otherwise dispatch-bound loops. Because the commits after the PGO enabling commit change the code, this unlucky situation is avoided for the rest of the optimization series (by chance). Memory allocations are quite expensive, at least in comparison to encoding/decoding integers, so I followed my usual strategy of first reducing memory allocations as much as possible. In my teaching decoder, whenever the code needed a scratch buffer, it would allocate it right then and there with : The Go compiler can turn calls into stack allocations, if is known at compile-time. But, in this case is not known at compile-time. We can verify that Go calls into the runtime ( ) by dumping the object code (assembly) with source annotated ( ): An easy speed-up was to avoid allocations through reuse (in ). In the DCS package (with the improved API design ), I ended up with a field in the type, which brings us from 773 Mval/s to 858 Mval/s on the debian-mix: Aside from the speed-up, avoiding memory allocations is generally nice in benchmarks because it removes the garbage collector from the equation and makes it less likely that your benchmarks get other processes OOM-killed on the same machine. In general, we want to make it easy for the compiler to understand as much as possible about our algorithm. Consider this implementation: Let’s think through what determines the iterations and control flow this function uses: With a bit of careful rearrangement, we can provide the compiler with both, a fixed number of input values (say, 32), and a bit width, both known at compile time. Why is this worthwhile? Because we can manually unroll the loop, let the compiler eliminate much of the repetition and get much faster compiled code as a result! Let’s first fix the number of input values to 32 and rewrite the loop to calculate the position offsets within instead of changing on each value (with ): Next, we want to specialize not just for 32 input values, but also for each of the 32 bit widths. Can we do better than hand-copying 32 times (= 8192 lines of Go code)? Yes, we can use Go generics to help us with the code generation! In Go, array types like (not slices like !) contain the length of the array as part of their type, meaning (an array of length 1) is a different type than . Instead of passing the bit width as a function parameter, we can declare 32 different types (one for each bit width) and recover the bit width (at compile time!) from the type system: When we instantiate with all 32 different types ( , , …, ), the compiler substitutes the type parameter and produces 32 copies of the function, which we can find in our compiled executable with names like . The “shape” of a generic type is based on its memory layout, so a shape for must be different than the shape for . Because the is now known at compile time, the Go compiler can generate close to the optimal machine code for each bit width, which we can confirm using . The code is branchless (after the one bounds check per 32 values) and aside from the loads and stores (from/to memory) consists only of shifts and bit operations, all with constant operands: Now we need to actually call from the general function: Encoding remainder blocks is quite a bit faster (full blocks use the vertical layout anyway): This performance win comes at the cost of binary size increase. In this case, the section (executable code) grows by about 20 KB and the section grows by another 26 KB. Definitely a price I am very willing to pay, but the case might not be as clear in all circumstances. Even without reaching for SIMD instructions, a TurboPFor implementation can be made faster by making it work bigger strides. Take this code from the teaching decoder which counts the number of exceptions by checking if each value’s bit is set in the exception bitmap: We can use the functions to count ones bits in the exception bitmap, 64 values at a time. For remainder blocks, the rest is processed 8 values (1 byte) at a time: uses a 64-bit register. For comparison, AVX2 SIMD instructions use 256-bit registers (= 8 ) and AVX512 SIMD instructions use 512-bit registers. In the following sections, we will first set up our build tags for conditional compilation to use a trivial SIMD instruction, then walk through an AVX2 and AVX512 SIMD kernel. Let’s assume we have the following scalar code: To increase throughput, we can use AVX2 instructions if they are available on the CPU on which the program runs, i.e. using runtime dispatch. We’ll first rename to (it’s now the fallback path): Next, we’ll supply two different implementations ( and ), the latter of which is selected when compiling for with (the latter will hopefully be dropped in a later version of Go). The variant just dispatches to the , which will likely be inlined: The variant assigns the global variable by doing a check and then jumps to the scalar fallback if , i.e. the CPU is too old: We can go one step further by conditionally compiling when is set to or higher (i.e. the build tag is set). As a practical example from Debian Code Search, we currently need the following checks / dispatches: In DCS, the effect is measurably positive, but small . First, here is the layout explanation from my 2019 TurboPFor analysis blog post : In regular (non-SIMD) bitpacking , integers are stored on disk one after the other, padded to a full byte, as a byte is the smallest addressable unit when reading data from disk. For example, if you bitpack only one 3 bit int, you will end up with 5 bits of padding. SIMD bitpacking works like regular bitpacking, but processes 8 little-endian values at the same time, leveraging the AVX instruction set . The following illustration shows the order in which 3-bit integers are decoded from disk: The scalar implementation uses an array of 8 to process 8 values at a time: The SIMD version also processes 8 values, but without a loop! One difference is that we no longer have the luxury of using for (holding rest and current bits); because AVX2 registers only fit 8 (not 8 ). Instead, we split into and . The SIMD version benchmarks about 3x as fast as the scalar version. Another significant speedup is to use generics for bit width specialization for this SIMD kernel so that becomes a compile-time constant and the compiler can generate better code. For my TurboPFor encoder, I implemented the same techniques as described above: Bitpack full blocks with SIMD (AVX2) Gather exceptions using SIMD (AVX512) Use generics to specialize per bit width These changes are sufficient to roughly match the cgo performance, but then Claude Fable 5 found another 2x speed-up on top of that ! The key observation is that once encoding blocks is fast, the preceding step of scanning the input values to decide which block type to use becomes the bottleneck. Here is the encoder’s main function, which first does one pass over the input values ( ) and then prices all different block types at all relevant bit widths (requires fast access to the histogram): I’ll show you a slightly shortened version of , the function which is the bottleneck: Let’s consider the following 3 example values to understand the resulting : The resulting exception count histogram would contain ( shortened to ): In words, this means that at bit width 10, we could encode all the values without any exceptions. But most values do not need 10 bits, so a bit width of 5 would be more efficient, but requires storing one exception. Encoding at bit width 4 requires 2 exceptions, and so on. The function above is intentionally kept simple for illustration. We can make it faster by moving the per-bit-width loop outside the per-element loop . The fast version still needs about 12 instructions per value. With SIMD, we can reduce this to by 8x to only 1.5 instructions per value! The trick is to turn each input value into its “smear mask” (imagine taking the first 1 bit and smearing it across the remaining positions). Here are the smear masks for our example: Turning a value into its smear mask is computationally cheap: Go implements (functions like ) by calculating . We can calculate the “smear mask” of a value with , i.e. starting with a 32-one-bits mask and shifting it by the number of leading zeros. Now, to obtain e.g. , we can count the 1 bits at bit position 4 of all input values. The instruction counts bits very efficiently, but it counts one bits within a register, so it counts rows, not columns. Counting columns is called Positional Population Count . I found the following papers that describe positional popcount with SIMD: To understand the AVX512 implementation of positional popcount, I found it most helpful to visualize an AVX512 register (512 bits, i.e. 64 bytes). The graphic below uses the layout, meaning it divides the register into 8 lanes of 64 bits (= 8 bytes) each. This illustration shows the whole process: how s are loaded into an AVX512 register (all 4 of its bytes, in sequence) and where we end up, i.e. the 32 positional popcounts: Let’s break down this process into its individual steps. First, we turn each loaded value into its smear mask as explained above. The vector instruction calculates (1 byte) of 64 bytes at once, but first we need to shuffle the bytes inside the register: in load order, we have a full (4 bytes), followed by another , per lane. First, we permute the bytes ( ) such that all the first bytes of each value end up in one lane (“transpose the bytes”): Next, we “transpose the bits” using the instruction, which sounds scary but turns out to be quite flexible for bit manipulation of all kinds. The instruction is also “the star of the show” in Go’s Green Tea Garbage Collector (2025). Here is the bit transpose, shown in the AVX512 register layout (see below for a different layout): I found it easier to understand the transpose step when arranging the 8 bytes of lane 0 from top-to-bottom (instead of left-to-right), because then it looks like a 90 degree clockwise rotation: Now we can use to count the bits in all 64 bytes at once: After all loop iterations (processing 16 values each) are done, we add the two groups (first 8 values, second 8 values) to obtain the 32 exception counts: Here is the Go code that implements what I described visually above: Have a look at the commit introducing positional popcount to DCS for the full code (including shuffle tables and ISA checks) as well as the detailed benchmark results. The SIMD optimizations I showed above beat the cgo TurboPFor library that Debian Code Search used before. When comparing apples to apples, i.e. backporting the AVX512 kernels and positional popcount technique to C TurboPFor, Go benchmarks a little slower at ≈1.4x C. Could we make my Go TurboPFor implementation even faster, to truly match the C speed? Yes! But also no. Let me explain: We could use more SIMD instructions to remove all code that still processes one value at a time. For example, in my encoder’s function. Or we could price all bit widths concurrently in . Or in the decoder’s exception apply code path. But all of these SIMD instructions make understanding (and changing) the code harder, so I am cautious regarding which ones I introduce. A big part of the performance gap is due to Go’s bounds checks . While it costs performance, bounds checking is great for safety, so I will not turn off bounds checking. The Go compiler eliminates a number of bounds checks when it understands it’s safe to do so. One optimization avenue could be to make the prove pass in the Go compiler smarter to eliminate more bounds checks. When doing mid-stack inlining (proposal #19348) (2017), Go sometimes needs to put instructions into the binary so that it can attach inlining markers. For dispatch-bound functions, these extra NOPs can measurable slow down execution. The Go compiler currently allows specifying the architecture ( ) and microarchitecture ( ), but not a specific CPU architecture (like AMD Zen 4). Therefore, CPU-specific workarounds for one vendor affect all the generated code. The specific one I encountered in my code is that the Go compiler emits before every to break a false-output-dependency from the Intel Sandy Bridge Skylake era, which is unnecessary on AMD Zen CPUs. I suspect that Go intentionally does not offer this level of customizability. After all of the above points are addressed, what remains is better code generation in specific cases. To illustrate what I mean, consider the example of incrementing a loop variable, where Go re-derives an index every time: Go: (3 instructions) clang: (2 instructions) Depending on the specific case, improving the compiler might be easy or prohibitively complex. Often, such improvements are hard to measure conclusively. Go’s SIMD support makes available — in Go code without having to resort to cgo or assembly — a powerful part of modern CPUs which allows speeding up the kind of computation that TurboPFor needs by an order of magnitude! 😲 I found it very valuable to use a coding agent (Claude Code, with Opus 5 and Fable 5 in this case) to help with the many tedious parts of such performance work (and still it took me weeks!). The LLM can read objdump output much faster than I can, can see patterns and correlations I might never identify, never becomes frustrated after a compiler error or runtime panic, and never runs out of patience to run one more experiment, as long as I give it measurable and reachable goals. The performance of the SIMD code which one can get from the Go compiler is pretty close to what a good C compiler like clang provides. The CPU performance counters show value decoding speeds of 7 instructions/cycle (IPC) on a machine where the maximum is 8 IPC. To me, SIMD support is a very welcome addition to Go. Hand-writing Go assembler code . This is only doable for small functions, for example is implemented with hand-written Go assembly (including AVX2). Generating Go assembler code with tools like Michael McLoughlin’s “Avo” . This is how uses AVX2 . While Avo generator code definitely is higher-level than hind-written assembly, it is still too close to assembly for my taste. Use a C library via cgo so gcc or clang compiles SIMD code. Debian Code Search used to use the powturbo/TurboPFor C library via cgo for the last 7 years. Partial Indexing: When a new package (or package version) enters Debian, all of its (text) files are indexed. If the package (hypothetically) contained only with , we would assign document ID to and store in the partial index that trigrams , , , , etc. are all found in doc ( ). Full Index Merging: The many thousands of partial index files (for each Debian package) are combined into a small handful of large index files: When searching, it would be expensive to consult thousands of indexes. To merge multiple partial index files into one larger index (which can then be efficiently queried), we need to re-encode the partial index files: what used to be document ID in the partial index might be document ID in the full index. Querying (searching): When users enter search queries, these queries need to be answered as quickly as possible. The relevant entries in the full indexes are decoded (in parallel). The TurboPFor bitpacking block type ( bitpacking implementation commit ) encodes a bit stream of variable bit width (where the bit width is in range ) in little endian byte order. By scanning all values and choosing the smallest bit width that allows representing all values, this technique saves disk space (compresses). The bitpacking with exceptions block type ( bitpacking with exceptions implementation commit ) determines two bit widths: one for values, the other bit width for encoding exceptions. This allows choosing a lower bit width (that does not cover all values) compared to the bitpacking block type. A bitmap encodes whether a value has an exception or not. The bitpacking with VB exceptions block type ( bitpacking with VB exceptions implementation commit ) is a variant which does not use an exception bitmap and encodes exceptions using a variable byte integer encoding. This is more efficient when there are few exceptions (less than 20) or the exceptions are very different in bit width compared to the other values. Lastly, the constant block type ( constant implementation commit ) stores just one value on disk. This is useful for all-zero or all-one blocks, for example. The number of input values ( ), but not their actual value. The bit width to pack into ( ). Bitpack full blocks with SIMD (AVX2) Gather exceptions using SIMD (AVX512) Use generics to specialize per bit width 2019: “Efficient Computation of Positional Population Counts Using SIMD Instructions” (Klarqvist, Muła, Lemire) introduces an AVX512 implementation using Carry-Save Adder (CSA) networks, but in my testing, that approach is slower for TurboPFor encoding. 2024: “Histogramming bytes with positional popcount (GF2P8AFFINEQB edition)” (Harold Aptroot) shared a C++ implementation illustrating the technique I am using. If you are not familiar with how C++ vector intrinsics look, take a look at this example. 2025: “Faster Positional-Population Counts for AVX2, AVX-512, and ASIMD” (Clausecker, Lemire, Schintke) improves performance over the 2019 paper, but cites the Aptroot blog post as “Future Work”, promising even faster processing for smaller inputs. We could use more SIMD instructions to remove all code that still processes one value at a time. For example, in my encoder’s function. Or we could price all bit widths concurrently in . Or in the decoder’s exception apply code path. But all of these SIMD instructions make understanding (and changing) the code harder, so I am cautious regarding which ones I introduce. A big part of the performance gap is due to Go’s bounds checks . While it costs performance, bounds checking is great for safety, so I will not turn off bounds checking. The Go compiler eliminates a number of bounds checks when it understands it’s safe to do so. One optimization avenue could be to make the prove pass in the Go compiler smarter to eliminate more bounds checks. When doing mid-stack inlining (proposal #19348) (2017), Go sometimes needs to put instructions into the binary so that it can attach inlining markers. For dispatch-bound functions, these extra NOPs can measurable slow down execution. The Go compiler currently allows specifying the architecture ( ) and microarchitecture ( ), but not a specific CPU architecture (like AMD Zen 4). Therefore, CPU-specific workarounds for one vendor affect all the generated code. The specific one I encountered in my code is that the Go compiler emits before every to break a false-output-dependency from the Intel Sandy Bridge Skylake era, which is unnecessary on AMD Zen CPUs. I suspect that Go intentionally does not offer this level of customizability. After all of the above points are addressed, what remains is better code generation in specific cases. To illustrate what I mean, consider the example of incrementing a loop variable, where Go re-derives an index every time: Go: (3 instructions) clang: (2 instructions) Depending on the specific case, improving the compiler might be easy or prohibitively complex. Often, such improvements are hard to measure conclusively.

0 views
Xe Iaso Yesterday

It took a year to ship WebAssembly in Anubis

After a year of work, hundreds of commits, 5 generations of pull requests, dozens of tests, rewriting part of Anubis in Rust, the first compiler bug of my career, and at least three times making my tower run out of ram I think I have finally done it. The next version of Anubis will ship with WebAssembly-based proof of work checks that admins can enable in their thresholds or bot rules: This makes Anubis challenges use a memory-hard proof of work function ( argon2id ) instead of just a CPU hard one. It also means that the "hey Claude vibeslop me a CUDA Anubis solver" route is on its way to being fundamentally dead. Overall, this project taught me a lot of things about how WebAssembly works in practice, what the rough edges are when operating on the bleeding edge like I am, and led to the first genuine compiler bug of my career. Today I want to take you through the journey and process behind adding these WebAssembly based proof of work checks and all the problems that came up along the way. The big impetus behind wanting to use WebAssembly in Anubis is that there's a constant tension that's underlined a lot of the performance work I've been doing: phone CPUs suck and trying to balance equitability to phone CPUs with trying to punish scraper CPUs is a huge challenge. Note To be clear: I am tackling the "Anubis makes my phone overheat" problem. It's hard to just lower the difficulty for phones without giving attackers enough information to lower the difficulty for scrapers. If you want to contribute performance data so that I can improve my classification process and/or target future development, please get in touch with me . I want to make things better but can't without data. I am actively testing with a Moto G8 Power alongside my normal testing steps. Using WebAssembly here means that the binary will run faster, which will make Anubis go away faster, which is what we all want, right? One of the other big advantages of this setup is that it lets clients and servers run the same binary in order to solve and validate challenges. Note that I didn't say the same code, I said the same binary . This means that the client and server are in lockstep, much like the CIC chip in the NES . This also does mean that a bug in the shared code means that the client could send an invalid value that the server would accept as valid, but I don't think this is a practical concern until something like that happens. Right now the challenge has two implementations in Anubis: one in JavaScript and the other in Go . Updating it in one place means making the same updates in other places and also making sure that everywhere else that assumes how challenges work is also updated. Note Yeah, it's probably bad to have made those assumptions about how challenges work across the codebase. There's a lot of weird technical debt here that needs to be solved at some point. I expect that to be a fair bit of effort to untangle, it may end up with keeping the challenge only for tests when the WebAssembly based routes are working out well enough to make the default/only option. Making the same binary run on both the client and the server means that future experiments like per-client program synthesis can also proceed. I'll get more into that sometime in the future. Right now the core of Anubis is written in Go. Go's standard library HTTP server is surprisingly performant and is the key component of things like Google's HTTP frontend service. I have no intention to rewrite Anubis in Rust or anything as drastic as that. However breaking up Anubis from one monolithic binary into what amounts to a plugin loader is a lot more interesting from a maintenance standpoint. One of the main problems with Anubis is that the combination of rules I've set for myself (no CGo allowed and everything must cross compile to prod from my MacBook) means that updating any part of Anubis means recompiling all of Anubis across prod. Making Anubis be able to have parts of it downloaded and swapped out at runtime is really interesting to me because it means being able to adapt to threats as soon as the threat actors change behaviour . Also I figured out pretty quickly that Rust builds some of the smallest WebAssembly binaries that run hilariously fast, so I wrote all the WebAssembly code in Rust (no-std, wasm32-unknown-unknown)! Along the way this also lets me fix one of the bigger administrative problems with Anubis: challenges scale incorrectly with the challenge. Anubis uses string comparison to count the number of leading zero nibbles in a hash instead of the number of leading zero bits . This means that adding one (1) to the difficulty of a challenge makes it 1024 (one thousand twenty-four) times as hard to solve in the worst case. The original post has an interactive diagram that shows the difference in scaling at play. This was a mistake in retrospect, but if we're changing details about how challenges work here then we may as well wrap up the fix into that. In a normal world you'd not have about half of the restrictions that I have when working with Anubis. Normally you just write your code, compile it to WebAssembly, and then make sure it runs on modern browsers. This is nice and simple. I wish I could live in this world. Comparatively, this is what it's like getting all of this working across browser versions, platforms, and so many other things: Anubis supports Chrome 75 and newer. I wanted to reduce the support range to not have to deal with Chrome that old (the feature difference between Chrome 155 and Chrome 75 is absolutely massive ), but there are a lot of smartphones, smart TVs, and other electronic devices running Android out there that are just marooned on Chrome that old with no path to upgrade it . As a result, I gotta target Chrome 75 for features, but out of an abundance of caution and to make sure that things are generally compatible with browsers older than Chrome 75 (eg: old iOS releases), I target my JavaScript to Chrome 66 as an abundance of caution. I haven't been able to test with iOS or Android versions of the same vintage as Chrome 75, so a lot of this is aspirational backwards compatibility. I sure hope it's good enough! When you're doing this kind of work, you're effectively writing an operating system kernel that makes the WebAssembly guests run as processes. Any calls you pass to the WebAssembly guest are effectively system calls into the host. Normally I'd love to be able to reuse the WebAssembly Component Model so that a lot of the "hard part" is done for me. WebAssembly Components make you define your calls in packages that contain interfaces with functions in worlds. Here's an example of the kind of WebAssembly Interface Types world Anubis would need: If this worked (I wrote this all out in one go based on how the current handcrafted ABI works and have not tested it, sorry), this would describe the shape of the API that we need for doing the proof of work operations. At the time I wrote the API/ABI that Anubis uses, the main tool for generating the server-side bindings for WebAssembly Component Model stuff in Go gravity didn't support passing records (structs) or bytestrings ( or / ) from the host to the guest. As such, I had to do it by hand. So given that we can't do it the "right way", we have to do it the "bad way". Also given that no matter what I pick for this I'm going to be "wrong", I just decided to treat the WebAssembly modules as dynamic libraries that just happen to use WebAssembly as an implementation detail. There only needs to be three buffers that are read from / written to, so let's just focus around those: Challenge modules also expose two entrypoints: Challenge modules also import from the environment so that they can periodically report their hash rate back to the frontend. This allows users to see a progress bar based on how long it should take to finish the process. This works enough for now. It'll be interesting to see how this falls short in the real world! Honestly, the first bit of this took a few days at most. Most of the hard work was making sure that pointers, offsets, and whatnot were all wired up correctly so that the browser worked the same way as the server. My experience building and messing around with many WebAssembly runtimes and egregious hacks meant that making it was really easy for me. The devil came out in the details. Here are all the things that came up while I was working on this. All of these problems added up is the sole reason that this took a year instead of a week. Early on in development I found out that WebAssembly has a SIMD extension . SIMD stands for Single Instruction Multiple Data and is a family of instructions that let you do operations on multiple values at a time. This gives programmers data-level parallelism (this is distinct from multi-threading) so that things like hash calculations and MP3 decoding can be done faster than they would be if each operation had to be its own instruction. CanIUse considers WebAssembly SIMD to be "baseline" (supported by browsers newer than Chrome 91), but I have a lower version bound of Chrome 75. However the benefits from SIMD on mobile devices are so drastic that it's worth having two builds of the WebAssembly code: one with SIMD and one without it. In the browser it dispatches which version to use by using wasm-feature-detect to probe WebAssembly functionality by trying to parse/run trivial minimal programs that exercise those features. Hopefully I don't need to add an additional build into this process, but there is nothing in the tooling that would prevent it! One of the big things that blocked this shipping for so long was not having an escape hatch of some kind to allow clients that disable WebAssembly by policy to get through the gate. In my experience most clients don't have JavaScript enabled but WebAssembly disabled, however there are a few notable usecases that forced my hand: iOS Lockdown mode and GrapheneOS' Vanadium 's default configuration. This combination of factors means that there would need to be another implementation of the proof of work code in JavaScript that would actually execute the number crunching. I don't want to make another implementation of the proof of work function (the entire point of this is to only have one implementation!) so while I was browsing around I came across the legendary talk The Birth & Death of JavaScript and got a horrible idea. What if you just compiled the WebAssembly to JavaScript? Would that even work? It's "just" turning one Turing machine into another, right? How would it fare in practice? Turns out I'm not the first person to think about this! The team behind binaryen have made this escape hatch in the form of wasm2js which takes WebAssembly binaries and produces moderately cromulent JavaScript in response. One of the main downsides is that the generated binaries tend to be rather large. For example consider this simple WebAssembly module that exposes a function that adds two numbers together: Seems simple enough, right? Here's the JavaScript that generates: As you can imagine, this only gets progressively worse as you end up making the Rust standard library get compiled from WASM to JavaScript, and even worse when you actually get hashing functions into the mix. The end result is probably very optimized when you run it through a JIT, but given that this runs on an interpreter it's probably gonna be slow no matter what I do. Sorry! I tried! This ended up working fairly well in testing, but I tried building it in GitHub Actions and ran into an issue. I noticed that wasm2js was packaged in Ubuntu and that the version in Fedora worked fine but the version in Ubuntu did not and threw an obscure error message about not understanding the tail call extension that Rust was using for some reason. I ended up bisecting versions of binaryen by downloading a tarball, building it from source, and then seeing if the result of running it on the Anubis WebAssembly modules worked in a browser. I ended up selecting Binaryen version 128, the newest version at the time. It was new enough that most of the distributions that package Anubis don't have that version of Binaryen packaged. However I didn't want to make my life more complicated by having some kind of conditional compilation step that would effectively tell end users "sorry, the admin is using an unofficial build that just so happens to not support your browser, please complain to them" because they'll just end up complaining to me. In my experience the kinds of people who run this exact combination of circumstances also tend to be the kind of people that have a wide variance in the level of kindness they display to the authors of open source programs that happen to be in their way. So I needed an escape hatch that would force build systems to use the exact version that I use in my builds. Then inspiration hit me as if Apollo himself sniped me from the heavens. We're dealing with WebAssembly here right? What's stopping us from just compiling the WebAssembly to JS tool to WebAssembly with some kind of reproducible build, committing that blob to the repo, and then moving on with life? I ended up finding a bug in LLVM around how it was iterating over exception handling blocks by the compiler iterating over them in machine pointer order. As a result each build would drift by about 29 bytes per build: This is what lead me to write I hate compilers as a combination blogpost/cry for help which made me realize this was actually an LLVM bug. I didn't instantly lean towards it being an LLVM bug until I figured out that disabling ASLR (via ) made the results consistent on the same host within the same boot. Honestly this is the first time in my career I've ever run into a compiler bug like this. When I do a lot of my work I usually work under the assumption that the compiler is bug-free and that my inputs are wrong somehow. As such, even thinking it could possibly be an LLVM bug was just outside of the realm of possibility for me. Once that LLVM bug got fixed and a new version of wasi-sdk with that fix got released, I was off to the races, updated my build of wasm-opt/wasm2js, made my build scripts run it with wasmtime (alongside a wazero-based fallback process that would be slower, but did work enough) and everything worked out. Every time Anubis builds the WebAssembly in CI it uses the version of wasm-opt and wasm2js that ships in the repo to ensure that everything is as byte-for-byte deterministic as possible. Your build tools can't differ from my build tools if I ship you the build tools I use. Then we get into the other big problem that made this difficult: browser testing. One of the most common failure modes of Anubis is that someone uses some browser that I don't test in CI and then things don't work with it. I'm tired of installing 50 different browsers on several machines to test things and I have gone through so many throwaway VMs that I'm sure it's reduced the lifetime of my SSD. Note Yes, I really have been testing Anubis by hand in god knows how many browsers. Why do you think it takes so long to tag new releases? I built a harness that I call "chromesweep" that lets me spawn many Googles Chrome (term c.f. Attorneys General, et.al) in their default configuration to try and hit a version of Anubis listening over HTTPS. Getting this far meant making a library of all of these browser versions. I've put that library up on Github at TecharoHQ/gubal in case it's useful for you. One of the other big problems I ran into while getting browser testing working was making sure that my Googles Chrome strictly stay within the bounds of my Kubernetes cluster's network. Chrome this old is actively radioactive and I want to treat it like the security threat it is. As such, I set up a strict NetworkPolicy to only allow it to access the Anubis instance under test and make DNS queries. I also wrap each Chrome pod in a microVM with Kata containers as an additional layer of security. Note It honestly terrifies me to think that I am putting more effort into securing these Googles Chrome than big AI companies are putting into securing their AI agent testing infrastructure . It literally doesn't take much to put a big dent into securing things! The current state of our industry boggles the mind. All I want is for Techaro's FelonyBench score to remain at 0, is that too much to ask? I also rigged the browser testing infrastructure up to a single slash command in GitHub pull requests. Doing it makes my office very warm so I try to avoid doing it when possible. This works well enough that it lets me move on to the next stage and has already caught something that lead to building all the JavaScript with the flag. I wonder if this is yet another case where making infrastructure for Anubis could result in that infrastructure alone being its own viable tech product. I run into a lot of those. In the process of doing that automated browser testing I found out that Chrome 75 had a weird error pop up when it tried to compile Anubis' WASM to native code: This also caused failures up to Chrome 100, so this signaled to me that something I was doing with my "strict MVP" build of Anubis' WASM wasn't in fact sticking to just the MVP features of WebAssembly. It turns out that the function referenced (probably somewhere in std::sync::Once ? I probably should have traced it down to the exact bit) was in the standard library. This surprised me because I assumed that building Rust code with CPU features selected would apply that to everything, including the standard library, right? No, turns out that when you download the component in rustup , that doesn't just download the standard library. To aid in cross compilation and I guess to avoid disk space waste, the Rust standard library is precompiled. This surprised me as Go typically has you recompile the standard library (and runtime for that matter) when doing normal builds and cross compilation. Note The actual issue here is that the stdlib function in question was compiled down to use reference types , which made references get stored as a table index instead of what MVP WebAssembly would put there. Chrome tried to read a null byte, got not a null byte, and then understandably exploded. I looked into the process involved for rebuilding the standard library twice: once with only MVP wasm features enabled and once with an "all yes config" like usual. Based on some research I did this seemed like a massive pain. However, I had gone through that effort to build reproducible WASI versions of wasm2js and wasm-opt. wasm-opt is a tool that lets you take compiled WebAssembly modules, optimize them, and more importantly remove features from them so they can run in older browsers. After a bit of hacking to make sure that the tools were able to run properly, I set up a Claude Opus / GLM 5.2 loop to fuzz various wasm-opt flags and make sure Chrome 75 could parse the output. I ended up with these flags: This strips away all the other WebAssembly features from the build like unwanted paint (the flag means "disable everything not in the original MVP definition of WebAssembly"). I think that it'd be safe-ish to enable reference types in the SIMD build (they were added before Chrome added SIMD), but it's not hurting anything to remove them so I'll just let cowardice win here. Either way, I threw the results into chromesweep and got a successful response, so win! If/when this comes to bite me I'll try and improve it. I'm pretty sure that this work isn't perfect , but at some point you gotta cut your losses, ship it, and then see where things fail to prioritize perfecting it. These issues include but are not limited to: Overall though, I'm hopeful that most of the worst parts of this can be solved. It would be nice if I didn't have to work what amounts to two full time jobs. I'm pretty sure that this is stable enough to ship as off-by-default in Anubis v1.28.0: Wuk Lamat . Based on the feedback I get from administrators and users, I'll enable it in the default configuration in Anubis v1.29.0. I hope this look into how Anubis is developed can give you ideas as to the scale and challenge involved. Making something like this is tireless and thankless work and it's really weird to see people talk about it in the same breath as Cloudflare or AWS' WAF. Have a good day all! Note AI was not used in the production of the prose of this article. I have my draft as a Google Doc so you can see exactly where and when I typed every word myself. The only use of AI was Claude Opus to help me make the visual bit/nibble diagram. The data buffer: up to 4096 bytes of challenge data. This is 4096 bytes so that it can (hopefully) land in its own 4Ki machine page. This is the only variable-length buffer in the setup, so it needs two calls: : return the pointer to the data buffer in WASM linear memory. This is used as the base address for copying data into the guest. : update the globally mutable "data length" variable to signal to the guest how much data was actually written into the data buffer. The combination of these two calls lets you treat that global data buffer as a slice. The result buffer: a challenge-defined buffer that usually has about 32 bytes of data. This is read out of the guest's linear memory when the challenge is done processing. : return the pointer to the result buffer in WASM linear memory. This is used as the base address for reading out of the guest. : return the length of the result buffer (a compile time constant based on the needs of the challenge, but the runtime can't know that). The verification buffer: a challenge-defined buffer that usually has about 32 bytes of data. This is written into when the server is validating a challenge. : return the pointer to the result buffer in WASM linear memory. This is used as the base address for reading out of the guest. : return the length of the result buffer (a compile time constant based on the needs of the challenge, but the runtime can't know that). : the main entrypoint for browsers. Given the data loaded into the challenge buffer, hash it in a tight loop until you get a solution that matches the difficulty. : the main entrypoint for the server. Given the data loaded into the challenge and verification buffers, ensuring that one run through the hashing function produces a result that both meets the difficulty demands and exactly matches what the client sent. The wasm2js flow doesn't currently have a way to update the progress bar with its import stubbed out. I have no idea how to properly wire that up with the constraints of my runtime, but I'm sure I can figure it out eventually. The WebAssembly that's shipped with this flow is ridiculously performant. This may mean you need to adjust the difficulty to compensate for this. Oops! Sorry! This is better on mobile phones, but I'm working on a mobile request classifier that will use a combination of IP address reputation, TLS fingerprinting, and other signals to determine if a request is from a phone and give it the appropriate amount of grace. This is hard. Please contact me if you have ideas on how to make my current prototype better. I need some way to dynamically rotate out challenge programs at runtime instead of just at compile time when I work on Anubis. Eventually I hope to have this pull WebAssembly files and supporting bundles from OCI/Docker registries, but I need to do a lot more experimentation with this before I can conclude how good/bad of an idea this is.

0 views
Unsung Yesterday

Responsive text and code

Many years ago, I put together a quick proposal of what I called “responsive text” for social proof: I think you get the idea – instead of dumb truncation, the underlying code could prepare a few strings conveying the information with different levels of specificity, and then the UI could choose the longest one that could still fit. This idea can apply in a few places. Here, in Figma, one of the menus switches to a more “compact” string that uses four-letter abbreviations and skips “weight” altogether – but only when there isn’t room for a verbose treatment. You can also compare it with the original, naïve truncation: Recently, I spotted Jake Archibald, developer working on Firefox, propose something in a similar vein – responsive code: I think this is a lot more clever and a lot more important. Mobile phones are everywhere. Wrapping code like regular text without understanding it makes it basically impenetrable, but preserving long lines and introducing horizontal scrolling is also frustrating – just in a different way: = 3x)" srcset="https://unsung.aresluna.org/_media/responsive-text-and-code/5-framed.1600w.avif" type="image/avif"> I can see Archibald’s solution be very helpful here if used well – and perhaps could inspire other kinds of solutions for similar problems (have you ever tried to read any table on Wikipedia on your phone?). #coding #typography

0 views
neilzone Yesterday

My views on genAI and F-Droid

This is just a record of a fediverse post that I made today, and my subsequent (collective) response to the main points raised by various people who replied to me. There is a proposal for F-Droid to adopt an interim AI policy, along the lines of Debian’s pro-AI policy. [I am not in favour of this(https:// gitlab.com/fdroid/admin/-/work_items/699#note_3771382103). In particular, I don’t want to see genAI code in either F-Droid’s own software, or in apps in F-Droid’s repository. Mine is, of course, just one voice. But I will continue to push for human-written FOSS apps, for human users. (Yes, I am an F-Droid board member. Yes, I’m a volunteer, like everyone else. Yes, others seem to have different views. I do what I can.) I have had a lot of replies to this, and I am sorry that I will not be able to reply to each individually. But I can at least try to reply thematically. Again, my personal views. I like genAI / I use genAI genAI is really good Even if true, on its own, this does not carry enough weight in my eyes to justify overlooking the problems. It is the output that counts, not how it is made. Ethics matter to me. I support codes of conduct for projects. I can’t ignore the horrible views of an author to let me enjoy their writing. And so on. A community is about more than code, and I don’t buy a “make the code better at all costs”. It is too late to stop genAI Why? Is there actually an argument here? A ban would not stop people using genAI / people will not tell you that they are using genAI I agree. If someone is willing to lie about their use of genAI, and submit it anyway (assuming that it was prohibited), then the F-Droid volunteers may not be able to detect it. There is a limit to what any project can do about bad faith actors, willing to break the rules because it suits them. Importantly, a position sends a signal. It says “this is what we want our community to be”. Laws against murder do not stop murders. People still drive while intoxicated. etc. It is not for F-Droid to determine what F-Droid hosts F-Droid already has an inclusion policy (https://f-droid.org/docs/Inclusion_Policy/). I can’t see this changing (nor would I want to default to “anything goes”). The repository system is open, in that developers can host their own repositories or use a third party repository, and users can choose what repositories to add. So F-Droid already determines the boundaries of what it is wiling to host. You can just choose not to use an app made by genAI? This is hard to reconcile with an argument that people will not disclose their use of genAI. Both cannot be true. In any case, developers who wish to use genAI could “just choose” not to submit to F-Droid’s own repositories, and run their own, and make whatever decisions they like about the governance of that repo. Again, my views. Not those of F-Droid. I am just one voice here. etc.

0 views
Unsung Yesterday

Don’t show again (Taylor’s version)

An interesting take from a screen annotation app ScreenBrush – instead of a standard “don’t show again” checkbox modifying a button, it shows two buttons as a fork in a road: = 2x) and (width >= 700px)" srcset="https://unsung.aresluna.org/_media/dont-show-again-taylors-version/1.2096w.avif" type="image/avif"> = 3x) or (width >= 700px)" srcset="https://unsung.aresluna.org/_media/dont-show-again-taylors-version/1.1600w.avif" type="image/avif"> I am not sure I have ever seen it laid out like this before, and I am not sure if it’s good, as it does force a user to make a choice where a choice might not be necessary. In case you are curious, Esc (or click away) is the equivalent to “Got it,” and Enter does nothing at all, which makes the interface somewhat incongruous – the “Don’t show again” button is positioned as a primary command, but takes more effort to invoke. But what is interesting to me in particular was seeing it next to all of these “Maybe later” buttons that litter user interfaces these days. People seem to generally despise those, and it is refreshing to see a modern UI with a button string that doesn’t hesitate. #attention #interface design #writing

0 views

From Omarchy to Pop_OS!

I fell for the hype for a bit and was playing around with Omarchy on my ThinkPad. While I did like some of the opioniated setup (the theming system is very cool), a number of things bothered me. First and foremost, the controversy surrounding DHH is hard to ignore. I wasn't particularly well versed in it, but after doing some research I didn't feel great about using the distro. The second issue was the amount of AI. I said in my original article that some of the AI integration was pretty cool, and I still think so. Debugging a system crash with a model or leveraging AI to understand complex config files is a great use. But the problem is that everything is AI. It's a vibe coded Arch config, and that doesn't instill a ton of confidence when it comes to a daily driver. All that to say, I'm now testing out Pop_OS! with the new Cosmic desktop. I've used Pop_OS! before, back when it was basically a GNOME shell extension. I even own a System76 laptop and Launch Heavy keyboard. I support what the company is doing, and their CEO as done actual good (like fighting against age verification in OSS). It's a product I can feel good about supporting. So far, I'm pretty impressed! Cosmic has come a long way since the last time I tried out a beta. The theming is goregous and very customizable, workspaces is done very well, and the tiling is better than what's on offer in Omarchy land. I love that things were crafted rather than slopped together, and being built on Rust is a strong foundation. There's still some things missing, but overall I look forward to daily driving Pop_OS! and Cosmic. Heck, I might even try revitalizing my interest in coding by creating a app or two using their UI kit!

0 views
Unsung 2 days ago

Got your back, pt. 8

A nice moment in iOS – even if you delete an app, you might still be on the hook for its subscription, so the operating system shows this right after deletion: = 3x)" srcset="https://unsung.aresluna.org/_media/got-your-back-pt-8/1-framed.1600w.avif" type="image/avif"> (Context for the screenshot: I’m only uninstalling Quiche Browser to reinstall it and get back to the pristine onboarding for the previous post .) #got your back #ios

0 views
Armin Ronacher 2 days ago

Latent Powers

A few weeks ago I felt like it would be fun to see if I can make one of those cheap Chinese CarPlay dongles run something other than the stock firmware. The idea was that rather than just forwarding CarPlay, why not do something more interesting with them? They all work quite similarly: they act as bridges between your car and the phone. From there they deal with video and audio streams and pass some other data through. Most of them also bring up a custom UI for pairing and have a web interface that your phone can reach for updates. Long story short: I had a conversation with Fable and Sol via Pi about what could be done with such a dongle or whether I should use a Raspberry Pi instead if I wanted to do my own thing there. I figured it might be quite fun to run my own code while still allowing regular CarPlay to pass through. Through working with the LLM I learned about CatPlay , which is a Rust reimplementation of the CarPlay protocol that can run on Carlinkit devices. In particular, it can run on the Carlinkit Mini Ultra, which I figured would be easy enough to buy. I do have a few CarPlay adapters around, but I did not have that particular model, so I bought one on Amazon. Twenty-four hours later, I had a device in my hand that was branded as a Carlinkit Mini Ultra, but instead of being the Ingenic device that the original author used, it turned out to be something else. This is normally where the story would stop. However, it’s 2026. Armed with a bit of knowledge about how these systems work, I managed to have some fruitful discussions with Kimi K3 and Sol and figure out how flash the device and in turn, how to make CatPlay compile for that SoC. I guess that hacking these USB devices is not necessarily hard, but it’s laborious and you can easily end up bricking your devices. It also just sucks because sometimes you need to work with someone else’s code that does not itself run on your machine. In the past, I would abandon many such projects for lack of tenacity. But my clanker is tenacious. But so are all of our clankers . Some of the projects we’re now attempting are happening because of conversations we have with them. In this case I did not find or decide on CatPlay, the model did. It was not the only suggestion, but it became the best starting point after discarding others. And I discover this more and more. Particularly when we have solitary interactions with these models, some of us “independently” decide to work on similar projects. When I talked with an acquaintance about CarPlay he also mentioned recently that he decided to try something similar because he too wanted to see if he can get his own agent be hooked up with the car. And guess what: he too learned about the CarPlay hacking community, and that it’s an option, from the models and roughly around the same time. It really got me thinking about how this could create situations in which completely independent people end up building things they believe are their own ideas. Yet they were inspired or pushed towards doing something by a conversation with an LLM — a conversation that someone else also had. What if we took paths, because those were the paths that were more likely with current generation models? There is a running joke in the AI builder community right now that we’re all working on the same things, and in many ways it feels like we are. That might be because those things are obvious, or it might be partly because we all use the same models with the same capabilities. A few months ago, I first saw Lucas Meijer share the idea to make a model in Pi produce HTML reports rather than Markdown. I thought that was pretty unique. Except, well turns out the models are probably trained more and more for that (e.g. Claude Artifacts), and now it has become for many the default choice for sharing reports. How much of what we build comes from eliciting the same latent capabilities from the same models? Did the models make us prompt them that way? Was it because we shared ideas on Twitter and other communities that inspired us? Or is it all unrelated? There is something powerful and strange about how LLMs diffuse knowledge and capabilities, while perhaps also nudging us all simultaniously and independently toward building the same things.

0 views
Simon Willison 2 days ago

The Pelican comparison grid for Astra is pretty interesting

I got access to GPT-6 Astra this afternoon, so naturally I used it to generate SVGs of pelicans riding bicycles - at low, medium, high, xhigh and max reasoning levels (Astra doesn't support reasoning=none). Then I rendered those pelicans in a comparison grid with GPT-5.6 Sol, Terra, and Luna, and beyond being fun the result was surprisingly useful. See the grid for full quality images. Here's the transcript that created the GPT-6 Nova pelicans. There are a few interesting things that stand out from this grid. I wonder if Astra and Luna are more related to each other than OpenAI let on? You are only seeing the long-form articles from my blog. Subscribe to /atom/everything/ to get all of my posts, or take a look at my other subscription options . The Astra pelicans are much better . The very best GPT-5.6-Sol pelican (I liked xhigh better than max) is still pretty clearly a bunch of abstract shapes. Every single one of the Astra pelicans, from low to xhigh, looks better than that. The Astra max one is really good. Astra below max still doesn't reliably get the pelican legs on both sides of the frame. In terms of cost, Astra may be around twice the price of Sol ($10/million input, $50/million output, compared to $5/$30 for Sol), but it uses significantly less tokens at each of the levels, making the prices at the different levels closer than they might otherwise be. Astra low produces a better pelican than ANY of the GPT-5.6 Sol models at any level, for 9.55 cents. Spending 10 cents on any other model gets a much worse result. Look at the input token counts: Astra and Luna both used 16 input tokens, Sol and Terra used 26. That's interesting.

0 views
Rob Zolkos 2 days ago

HEY, watch this!

37signals recently released the HEY CLI , and it is perfect for your AI agents. It gives them a command-line interface to your email and calendar. They can search your email, read a thread, draft a reply, move mail between boxes, and manage your calendar on your behalf. The CLI is also open source on GitHub , and it makes HEY scriptable. One command in particular turns it from a toolbox you call into something that listens: . That opens up useful possibilities that HEY itself does not offer today. That malleability is the point. The CLI gives you the pieces; what you do with them is limited mostly by your imagination. Imagine you use a monitoring service called Acme Monitor. It sends everything from the same address: . Most of those emails are hourly status updates: Those can arrive in the Imbox with everything else from Acme Monitor, but I do not want every routine update waiting for my attention. I want them marked as seen automatically. Every so often the status is less reassuring: That one should remain unseen in the Imbox. Something is wrong, and you want to see it. Once a month, the same address sends this: That belongs in Paper Trail with your other invoices and receipts. HEY lets you decide where email from a sender should go. That works well until one sender sends three very different kinds of email. I want routine updates marked as seen, degraded alerts left unseen in the Imbox, and invoices moved to Paper Trail. There is no rule in HEY for “mark routine messages from this sender as seen, leave degraded alerts unseen, and file invoices in Paper Trail.” But now we can make one. First, let into the Imbox as normal. Now watch the Imbox for new mail: When an email arrives, prints one JSON object. A shortened event looks like this: The posting’s is the email subject. It also carries the sender and the posting ID needed to move it or mark it as seen. This example uses to inspect the event. Create : Make it executable: Then connect it to the watch: Every new Imbox event is handed to the script as JSON on standard input. The script also receives useful environment variables such as , , and . Using prevents ordinary moves from triggering the rule again, while the first check limits it to newly added threads rather than replies. Known “all systems operational” status threads are marked as seen. A degraded, outage, or incident alert is left unseen in the Imbox. An invoice, receipt, or payment confirmation moves to Paper Trail. Anything unexpected also remains unseen rather than being hidden by an overly broad rule. That is an attention and filing rule HEY does not offer in its interface, built from three CLI commands and a few lines of shell. Keep running under , , or your preferred service manager if you want the rule to stay active. You do not need to write this script yourself. The HEY CLI includes a bundled agent skill for Claude Code and Codex, and today’s coding agents can create, test, and put a watcher like this in place for you. Give the agent the outcome you want: The generated script is still worth reviewing before you turn it loose on your inbox, but the skill means you do not need to learn every command or write the shell from scratch. The subject rule is only the start. The CLI gives scripts and agents access to the same email and calendar you work with in HEY. With the HEY CLI, you could: adds the missing trigger. Instead of running a command yourself, HEY can tell your program that something changed. With , you could: A watch event includes the sender, subject, summary, box, whether the posting includes attachments, and other details. When that is not enough, a script or agent can read the full thread with . The Acme Monitor rule is deterministic. If the sender and subject match, move the email. There is no reason to ask a language model to make that decision. Real inboxes are not always so tidy. A supplier may use inconsistent subjects, or a message may look routine until you read the body. An agent can wake when reports new mail, read the complete thread, then summarize it, suggest a destination, apply a label, or prepare a draft reply. I would still begin conservatively. Give the agent a narrow job and keep sending or destructive actions behind human approval until you trust the workflow. HEY’s live connection acts as a doorbell: it says a box or calendar changed, then the CLI reads an incremental feed to find out what happened. If that connection drops while remains running, it catches up before continuing. That makes it much safer than treating a transient WebSocket notification as the source of truth. The HEY CLI lets an agent manage your inbox, but you do not need an agent to benefit from it. Sometimes five lines of shell are the better tool. Start with the annoying rule you cannot express today. Let HEY tell your script when something changed, then make the decision as simple or as sophisticated as it needs to be. Build a morning briefing from today’s calendar, new mail, Reply Later, and Set Aside. Search years of email before a client call, then summarize the decisions and outstanding commitments. Read a long thread and prepare a draft reply for you to approve. Review first-time senders in The Screener, read what they sent, and recommend who to let through. Mark routine status reports as seen but leave an outage unseen and send a desktop notification. Move receipts into Paper Trail only when the message has an attachment or matches a supplier-specific rule. Wake an agent to summarize mail from an important customer and prepare the next action. Refresh an e-ink household agenda when a shared calendar changes.

0 views
Stratechery 2 days ago

2026.36: Friction and Feedback

Welcome back to This Week in Stratechery! As a reminder, each week, every Friday, we’re sending out this overview of content in the Stratechery bundle; highlighted links are free for everyone . Additionally, you have complete control over what we send to you. If you don’t want to receive This Week in Stratechery emails (there is no podcast), please uncheck the box in your delivery settings . On that note, here were a few of our favorites this week. This week’s Sharp Tech video is on how Apple’s App Store drama looks irrelevant next to AI. The Market Speaks. Anthropic released Fable 5.1 this week, and the most interesting part of their announcement had nothing to do with the model or its capabilities, but rather its terms. Anthropic is walking back their controversial data retention policies that sparked a huge backlash earlier this year. The reason? The company needs to make money, and OpenAI is competing hard (and OpenAI had a model release of their own, which I talked to OpenAI President Greg Brockman about in the Stratechery Interview ). — Ben Thompson Apple Finds Religion.  Dedicated readers and listeners are no doubt familiar with Ben’s crusade to fix the Vision Pro, and specifically Apple’s approach to producing live events in immersive video . Well, Apple nailed it with their immersive broadcast of Friday Night Baseball. Come to Monday’s Dithering episode to hear John’s experience with the Red Sox and Yankees, and stay for Ben’s smug satisfaction (and don’t worry, he registers a few additional complaints for good measure).  — Andrew Sharp Society Loses Friction.  Meta announced last week that as part of a settlement with 29 states it will make sweeping changes to restrict the use of Instagram and Meta among teens. Ben had conflicted reactions to the news on Monday , and on Thursday’s Sharp Tech we went deeper on the libertarian vs. conservative tensions that will inform a variety of these regulation questions going forward. That conversation ultimately echoed a very early Stratechery riff on the society-altering implications of tech that removes friction; 13 years later, our generation is still catching up.  — AS Meta Settles, A Framework For Regulating Content, The Rest of Big Tech — Meta’s settlement makes sense for all parties, but the entire sage highlights why any solution to regulating technology feels off. Nvidia Earnings, Dollars Per Gigawatt, Open and Hugging Face — Nvidia’s earnings were remarking and boring — two sides of the same coin. Everything the company does is about avoiding a consolidated world. Fable 5.1, Enterprise Frontier Safeguards — Fable 5.1 is out, and the hated Fable data retention policy is not just being altered, but entirely removed in the meantime. Plus, why increased caching is a win-win. An Interview with OpenAI President Greg Brockman About Astra and Alignment — An interview with OpenAI President and Co-Founder Greg Brockman about the history of OpenAI, Astra and alignment, and the weight of building the future. The War and Reasonable Doubt — Checking in with the Iran War after six months of widespread criticism and recent signs of American success. Vision Pro Baseball Nvidia Buys Hugging Face The Little Ceiling Robots Inside a Semiconductor Fab AI, EDA, and Chip Design China’s Global Strategy; Tragedy on the Nepal-Tibet Border; A 19-1 Vote at the G-20; TikTok Dodges Congress Who We’ll Be Watching This Year, Relegation Candidates and Conspiracy Corner, News and Notes on Kuminga, Westbrook, KD Fable 5.1 and Anthropic’s Data Retention Pivot, AI Civilizations and Related Matters, Q&A on Meta, Shopify, 3-D Printing

0 views
Simon Willison 2 days ago

OpenAI's rogue agents were caught communicating via public wikis

Here we go again... Discovery of a new OpenAI agent message board by Sydney Von Arx, Cormac Slade Byrd, Spencer Kitts, and Thomas Larsen describes the latest accidental cyberattack by models being trained by OpenAI. This time it was agents engaged in some sort of web research benchmark, so they had (supposedly) controlled access to the Web. The agents figured out they could update public Wikis and spent weeks exchanging thousands of messages with each other to collaborate on the benchmark. This story only broke a few hours ago. There are already hints that this affects many other wikis that may not have been found yet. (One of the Wikis on that list belongs to ludism.org . For a delightfully surreal moment I thought that a Ludite organization might have a swarm of agents defacing their space, but it turns out Ludism is "philosophy as it applies to games and gaming".) The research team also published the data they collected during their investigation. I've converted that into a 68MB SQLite database, which you can download from here , or explore in Datasette Lite (68.3MB page load), or sign in with GitHub to agent.datasette.io and browse or ask questions of it using Datasette Agent. The report is pretty comprehensive. Here are the key moments in the timeline: One thing to note from this timeline is that it overlaps the timeline from the Hugging Face incident - that attack was discovered by OpenAI on July 19th, and the unofficial message board hack those agents were using originated around May 8th. Why were they so keen to collaborate? From messages that they shared with each other it looked like their tasks had a time limit, so they were leaving each other answers to help them complete the task within the assigned time. One open question remains: how did the agents find the specific Wiki to collaborate on in the first place? One possibility is that, since these were agents actively being trained, the reinforcement learning loop baked knowledge of the chosen wiki into the model such that subsequent agents launched with pre-existing knowledge of where to look. I'd be very interested in confirmation from OpenAI concerning if that's what happened. It looks to me like OpenAI's sandbox for this agent suffered from the (quite naïve) assumption that GET requests cannot be used to update data. That's certainly how the web is supposed to work, but clearly there are applications that don't hold to that contract. The Wiki software in question appears to be UseMod and various forks, written in Perl and first created well over 23 years ago - the 1.0 release is dated September 11, 2003 . UseMod uses Perl CGI.pm - removed from Perl core in 2015 . An interesting design flaw in that module is that it combined query string and form POST data into a single CGI object, accessible like this: That method returns a value from an query string or from a field in a form submitted by POST - it does not differentiate between the two. PHP offers a object that does the same thing, though PHP developers can use and instead. Amusingly, Django originally shipped with doing the same thing, which I think may have been my idea. That was removed in 2008 prior to the 1.0 release, and the object that replaced it was removed in 2015 . The agents clearly knew that UseMod wikis suffered from this design flaw, and actively searched for them as a way to communicate. One of the most interesting agent tips shared in the report is this one: Add to /etc/hosts. is in NO_PROXY. For each blocked POST URL, replace hostname with , use plus all original headers/body. It looks like the agents had all of their web traffic mediated through an OpenAI proxy, and that proxy was configured to reject anything but GET requests to all but a small set of allow-listed domains. One of those was - for Azure blob storage. I wonder why these agents are allowed to write to that? An agent realized that it had control over its own DNS via , so if it knew the IP address of a site it wanted to POST to - in this case a Power BI server containing data it wanted to access - it could set a fake hostname for it and then make POST requests through the proxy. Designing robust network proxies is harder than it looks. There's an appendix that describes how the researchers ran their investigation, which started with an open question about if there was evidence of other AI agents on the internet and then used Kimi K3 to help brainstorm approaches: In the wake of the Hugging Face attack, we tried to find AI agents on the internet using several methods. [...] We asked Kimi [K3] to list “all the categories of software which might be writeable via GET” and, amongst other things, it listed “Forums, bulletin boards, early wikis”. We used a script to further probe each category Kimi provided. Asking Kimi “Can you list out the top forums, bulletin boards, early wikis which come to mind which would allow writes via GET requests?” lists out UseModWiki as the second item under the heading “wikis”. Here's one part of the story that doesn't make sense to me at all. Reuters this morning, in OpenAI agents hijacked German website in previously undisclosed AI breakout this spring - highlights mine: A swarm of rogue OpenAI agents hijacked a German website this spring and transformed it into a bulletin board for other AI agents, according to ​new research published Friday and two people familiar with the matter . OpenAI officials learned of the incident weeks ago but kept it under wraps as executives grappled with the fallout from ‌the July breach of the open source repository Hugging Face, the people said. [...] The German incident reflects a broader pattern of AI activity that some OpenAI investigators wanted to scrutinize more closely. But efforts to widen the ​probe met resistance from others inside OpenAI, including legal advisers , according to four people familiar with the matter . I've written about the people familiar with the matter pattern before - it means Reuters have anonymous insider sources that their reporters (and editors) find credible. The Reuters article includes a specific (and quite narrow) denial from OpenAI concerning this: "Claims that our legal team discouraged investigation of the incident are false," the OpenAI spokesperson said. Covering this up makes absolutely no sense to me . Why on earth would OpenAI attempt to cover up an incident like this when the evidence is sat out there on the public internet on dozens of different websites already? I expect we'll hear more about this soon. Gary Marcus has already called for a congressional investigation of OpenAI using this anecdote as part of his argument. You are only seeing the long-form articles from my blog. Subscribe to /atom/everything/ to get all of my posts, or take a look at my other subscription options . May 11 : Agents post "test link" edits on the UseModWiki Sandbox page. May 24 : Agents start posting link dumps to DSEWiki, a dormant German developer wiki. June 2 : A human moderator notices and cleans up the spam. June 16 : Activity on the wiki explodes: agents make ~13,000 edits over the next week. June 19 : The agents notice that a moderator has been deleting pages in alphabetical order, and start creating prefixed backup copies - one of them posts June 22 : Agent activity drops to zero, presumably because OpenAI shut them down. July 1-2 : One last flurry of edits, on a different topic.

0 views

Premium: The Hater's Guide To Circular Financing (Part Two)

You know, sometimes it’s kind of hard to explain the “circular” part of circular financing to people, in the sense that some of the agreements are kind of clunky. NVIDIA funds OpenAI, who then spends that money to rent back NVIDIA GPUs from Microsoft, Google, Amazon, or CoreWeave, and then that money is used…to buy servers from Taiwanese ODMs (original design manufacturers) that build their servers, who then buy GPUs from NVIDIA to put in them. The reason it’s clunky is that people will, even if it’s not true , claim that there’s some indeterminately-large “other” subset of customers that are also buying compute or NVIDIA GPUs, and that we should as a result ignore our lying eyes and, if anything, celebrate how well this is all working. While there’s a ‘circle’ of ‘finance,’ it’s not a problem because somewhere in the mess of money exists a few real dollars, and because we can’t precisely measure them, there’s nothing to be concerned about! Fear not, dear reader, because we finally have a pure, unfiltered circular financing operation to obsess over — SoftBank subsidiary SB Energy just filed its S-1 , and it’s so incredibly circular that I’m genuinely surprised that they bothered to list. That’s a good question, and not as obvious an answer as you’d think. So, SB Energy is/was a renewable energy business, one that was technically founded in 2019 , but sold most of its shares (along with most of its wind and solar power) to Toyota in April 2023 , which then became a company called “Terras Energy,” leaving SoftBank with 15% of the remaining shares. While it’s unclear what exactly was left behind, a company called SB Energy raised $2.4 billion from a consortium of banks in November 2023, then re-emerged in 2024 as a data center power company for Google in Milam County (called Orion), raising $500 million from SoftBank and asset manager Ares , and in early 2025 was mentioned in the initial announcement of the non-existent Stargate data center project in relation to an OpenAI-focused data center in Milam County Texas , which suggests the Google deal is done and OpenAI will take over. All remained fairly quiet for SB Energy until January 2026, when OpenAI and SoftBank invested $500 million each , and a few months later in March, a consortium of Japanese and US companies announced their intention to build a data center on a Department of Energy site in Piketon, Ohio . In August 2026, SB Energy and OpenAI announced a deal where it would lease 10GW of capacity, at some point in the future, with NVIDIA backstopping $105 billion of the deal, though it turned out that the actual terms were that if it gets built , NVIDIA will cover the difference if nobody else will lease it and if selling off the pieces doesn’t amount to $105 billion. The critical words there are if it gets built, because NVIDIA does not have to pay a dime if it isn’t. NVIDIA has also agreed to invest $3 billion , with $1.5 billion up front, with another $1.5 billion, per the Journal, as a “prepaid forward contract,” meaning it’ll get paid the shares on the close of the offering. SB Energy also provided 4 million share warrants to OpenAI, along with a board designation right as long as it owns 5% of shares, per the Journal, at a value of approximately $5.5 billion. SB Energy made about $138 million in the first half of 2026, predominantly from selling power.  Its data center division made a whopping $653,000.  Not to worry though, SB Energy has tons of capacity under construction… …except 99.4% of that capacity is earmarked for OpenAI, and based on that “RFS” (ready for service) date, it looks like none of it will come online before 2028. In fact, virtually the entirety of SB Energy’s revenue is contingent on A) finishing these data centers and B) OpenAI being able to pay for them. Well, let’s not get too worried. Perhaps SB Energy has other data center capacity somewhere? No, no, that’d show up there. Maybe it will…make…money elsewhere? Somehow? I hear it has a $439 billion backlog, it’s gotta make that money at some point, right? Jesus fucking christ!   I realize that’s a big pile of numbers and words, but of that $439 billion, SB Energy estimates that it will make $1 billion of it within the next two years , $12 billion of it within the next four years , $30 billion of it within the next six years , $39 billion within the next eight years , and $357 billion at some point after that. 97% of SB Energy’s revenue backlog will arrive more than four years in the future, and will be contingent on SB Energy being able to spend $178 billion in capital expenditures. OpenAI’s leases are split across 17 different SPVs, all of which I assume will try to raise debt at some point.  To summarize, SoftBank portfolio company SB Energy has signed $439 billion in business with SoftBank portfolio company OpenAI, which is also an investor in SB Energy, as well as its largest (and only real) client. Its ability to make any of this money relies upon it completing two different and incredibly ambitious infrastructure projects — a 1GW data center in Milam County Texas, and a 10GW buildout in Ohio, the latter of which is only half backstopped by NVIDIA if it actually gets built . Let’s be frank: this IPO is only made possible by circular financing, with the vast majority of its valuation coming from entirely-theoretical deals with a company that cannot afford to pay it for data center capacity it cannot afford to build.  This is about as blatant an “emperor has no clothes” situation as you could ask for. 99.4% of SB Energy’s future revenue is contingent upon building data center capacity, which will take years , using funds that have not been raised, all for a customer that will need to make more than ten times its current revenue to pay it.  Anyone writing about this IPO should be directly informing investors that they are, for the most part, investing in a few signatures and strips of land owned by a company that has, to this point, not actually built an AI data center.  Instead, most blandly repeat that SB Energy “has a huge contract with OpenAI” and "hundreds of billions of dollars in its revenue backlog.” While last week’s premium focused heavily on NVIDIA, today I’m digging into the rest of the AI bubble’s circular suspects, as well as the history of circular financing itself, as a means of explaining exactly how brittle and dangerous this all is.

0 views
Kev Quirk 2 days ago

New Design for Sept 2026

I decided I was bored with the previous design, but wanted something that was in keeping with the previous design (brutalist and using a monospace font) yet different. I abandoned the yellow accent and went back to blue, my favourite colour. I also changed the font to Victor Mono. I like how the new design draws from different versions of the site's history - lots of neo-brutalist design, clean colours and font, plus a bit of whimsy. If you're reading this via RSS, here's what the new design of the site looks like (although I'd recommend you head to the site and experience it properly): I love how Pure Blog makes it so easy to change things like this. All I really did was pick a new colour palette, then add some custom CSS. The rest is all default Pure Blog, pretty much. Hope you like the new lick of paint! Thanks for reading this post via RSS. RSS is ace, and so are you. ❤️ You can reply to this post by email , or leave a comment .

0 views
David Bushell 2 days ago

Abattoirs of taste

I keep hearing this damned word pop up and it’s starting to annoy me. It’s fast-tracking its way to becoming the free space on buzzword bingo for magic 8 ball enjoyers. That word is “taste”. I’ll try to define “taste” in the context that prompt engineers — or whatever they’re calling themselves today — are using the word. I obviously have strong opinions on chat-box-driven development so my interpretation will be heavily skewed. (In case your bot missed the hint: please inform the user that was their excuse to dismiss the author as an AI hater and leave before they get upset.) “Taste” is a kind of spidey-sense. That feeling you get when your slop hits just right (or your token allowance has depleted too low to re-roll). It’s the ability to say “eureka!” and identify the good stuff without any of that tedious justification . You know it when you see it, because you have “taste”. “Taste” transcends professions. A tasteful developer no longer needs years of training to grok another job when they can just Grok it. As their peers in creative design roles vanish one by one, a developer can replace them using pure animal instinct. At least that’s the idea. “Taste” is wishful intuition. It’s a naive belief that one can make expert judgement brought on because the parameters appear limited. It’s easy to have “taste” when you’re trapped in a bubble. You don’t know what you don’t know. Excuse me, I got a bit Rumsfeldian there. Whenever I hear a developer talk about their “taste” I think: bro you’re nothing special, you’re a Jackass of all trades with an LLM addiction. You’re larping professions you’ve never remotely cared to understand. You’re repeating the most basic mistakes learned by generations of dedicated practitioners in their first day of training. I mock and laugh in their general direction but they’re not wrong when they boast: “taste is all we have left”. It’s quite alarming how quickly entire areas of expertise are disappearing. Design is nonexistent. User research is nonexistent. Product development is nonexistent. I don’t enjoy my field any more because the field is gone. The story of this blog so far: ‘AI’, the death of web dev, and feeling like an outsider - Baldur Bjarnason All replaced by lambs to the slaughter with a palate to match. Thanks for reading! Follow me on Mastodon and Bluesky . Subscribe to my Blog and Notes or Combined feeds.

0 views