Latest Posts (20 found)
Unsung Today

Unsung Heroes: Super Sprint

I know, I know. I’m supposed to say iPod’s click wheel, or the Western Electric 500 rotary dial, or maybe the first Nest. = 2x) and (width >= 700px)" srcset="https://unsung.aresluna.org/_media/unsung-heroes-super-sprint/1.2096w.avif" type="image/avif"> = 3x) or (width >= 700px)" srcset="https://unsung.aresluna.org/_media/unsung-heroes-super-sprint/1.1600w.avif" type="image/avif"> = 2x) and (width >= 700px)" srcset="https://unsung.aresluna.org/_media/unsung-heroes-super-sprint/2.2096w.avif" type="image/avif"> = 3x) or (width >= 700px)" srcset="https://unsung.aresluna.org/_media/unsung-heroes-super-sprint/2.1600w.avif" type="image/avif"> = 2x) and (width >= 700px)" srcset="https://unsung.aresluna.org/_media/unsung-heroes-super-sprint/3.2096w.avif" type="image/avif"> = 3x) or (width >= 700px)" srcset="https://unsung.aresluna.org/_media/unsung-heroes-super-sprint/3.1600w.avif" type="image/avif"> But, have you ever played Super Sprint? It was Super Sprint that had the first amazing rotary controller I’ve ever used, and the whole cabinet design told you the game was very well aware of that. = 2x) and (width >= 700px)" srcset="https://unsung.aresluna.org/_media/unsung-heroes-super-sprint/5.2096w.avif" type="image/avif"> = 3x) or (width >= 700px)" srcset="https://unsung.aresluna.org/_media/unsung-heroes-super-sprint/5.1600w.avif" type="image/avif"> Super Sprint was a 1986 arcade game from Atari that was, in a nutshell: four cars (at least one computer-controlled), eight tracks, fast races. You might think that those wheels functioned similarly to a regular car steering wheel, but not really – they were much easier to spin, and needed to travel further to rotate the car. You wouldn’t steer delicately, but rather the opposite: you needed to throw the wheel violently in one direction, and then, at the perfect moment, stop it on a dime : So the huge wheels were not realistic. Neither were the cars. They accelerated rapidly – the gas pedal was your only other control – and had a ridiculous amount of understeer. Don’t let the size and intensity of the interface fool you, though: this was a very precise operation. The game was tight – Rollercoaster Tycoon tight or Excel 97 tight, a whole decade before them. In the world awash with slow computers, Super Sprint lived up to its name, laughing latency and delays in their faces. It’s hard for me, even today, to imagine something faster or tighter – and for even my contemporary work, it’s good to remember things can feel this way. And, on top of all that? Better-than-usual sound design , higher-than-usual resolution, shortcuts to reward really good players, and a bunch of great details and easter eggs. Super Sprint wasn’t Atari’s first attempt here – it was preceded by Sprint 2 , Sprint 4, Sprint 8, and Sprint One – and you could tell. It was designed and coded by Kelly Turner and Robert Weatherby, polished as hell, and might have been the first interface between the person, the hardware, and the software that really inspired me. It was almost as much fun to watch three good players compete, as it was to play yourself. But when I played it, it’s possible these were my first – please excuse me here – motor memories. Atari used the same wheel for other games, famous and obscure , but this was where it met its match in software. I can show that to you, but experiencing it is impossible from afar. Even perfect emulation can’t do it justice – there has simply never been a home controller that approached it. (I mean, the whole cabinet weighed 400 pounds!) But if you are ever in an old-school-themed arcade, look out for Super Sprint ( locations ), or its two-player cousin Championship Sprint ( locations ) – and give it a turn or two. = 2x) and (width >= 700px)" srcset="https://unsung.aresluna.org/_media/unsung-heroes-super-sprint/8.2096w.avif" type="image/avif"> = 3x) or (width >= 700px)" srcset="https://unsung.aresluna.org/_media/unsung-heroes-super-sprint/8.1600w.avif" type="image/avif"> #direct manipulation #flow #games #history #performance #unsung heroes

0 views

How I designed my SaaS landing page with AI tools

Hey, what's up? It's Takuya here. Recently, I rebuilt the landing page for Inkdrop , which has a demo app built with the real components. It self-plays an editing session when you visit, and at the same time, you can actually edit notes in it. I just posted a new video on YouTube where I share how to build an interactive self-typing editor demo with Waku + CodeMirror . The video walks through the steps in the way of traditional manual coding. But I used Claude Code to build it in my real workflow. In this article, I'd like to share how I experimented with designing using AI tools, which websites I referred to, and what prompts I used. The video focused on the technical part of the editor demo, while this article focuses on the design part. The end result is not what I directly created in one iteration. Of course, I explored a lot of other ideas and possibilities. Here is what I did. I've been thinking of building a new website since last year. I've been taking notes for inspiration like this: As you can see in the note, I've been considering using Three.js to create an interesting effect to make the page attractive. And I'm a huge fan of Oğuz's works . So, I really wanted to replicate his design style. Last year, I used Vercel's v0 to design a TOTP form, and it worked great. So, I tried v0 again. I iterated on a few prompts, but the results looked mediocre and boring. After that, I tried GPT Images 2.0 and it blew my mind. The output image looked so nice: I don’t usually overreact to AI hype, but GPT Images 2.0 is actually insane. I got this landing page sketch for Inkdrop from a one-shot prompt that included summaries of my app concept, the new features in v6, and my recent blog posts about Japanese culture. I never imagined web… pic.twitter.com/V7HJwIwU0e So, I fed the image into Claude Design to implement it: The layout was somewhat broken, but it got a little bit better than v0. However, the Japanese prose ("春は曙、やうやう白くなりゆく山ぎは。") doesn't make any sense on a product website. Here is another iteration: lol, this one was too Japanese-ish. Who'd expect to see a red sun on a SaaS landing page? Also, I've noticed recently, when visiting other websites, that Claude has a tendency to generate a similar look and vibe. So, I disliked these taste slops . I also tried VoltAgent/awesome-design-md , but it just generated an average design. Maybe useful for creating websites that don't have to be unique. It wasn't for this project. I was planning to use Ship Studio , but didn't use it this time. These experiments weren't waste of time. It let me quickly try various ideas and find a right direction. And it was fun to try new AI-native design tools. I think they should work way better than when I tried. While checking out Oğuz's work like this and this , I found that they are absolutely cool, but there is a critical issue in these designs. That is, I couldn't understand what the product does and how it works because my attention was caught by these amazing UI mocks and animations. I simply couldn't focus on the content. Also, it seemed hard to maintain as a solo developer. On the other hand, I liked simple websites like Sketch , fly.io , and Stripe . So, I decided to go with this simple design direction. What I don't like about screenshots and videos is that I can't actually try the product. Of course, it's technically impossible if your product is a native app like Sketch. But mine is an Electron-based app, whose UI is compatible with browsers. Since I really wanted to replicate Oğuz's style, I tried to mix these ideas: This way, I can achieve an attractive design while providing a demo so visitors can quickly understand how it works. I refactored the desktop app source to extract its React components into a separate library called . It uses Storybook to check the component designs. So, I can easily update the website whenever I change the app's UI. I followed Radix UI 's design pattern. This wouldn't have been possible without the help of AI agents, because it required lots of work! Here is the idea note that I had AI read. It explains the idea briefly: Then, I asked AI to create plan notes with implementation phases in detail. This one ended up being a super long note: I refactored and reviewed the components one by one, not in a single shot. Claude Code often made mistakes and broke the component behaviors. You may know I've been publishing videos on YouTube ( devaslife & craftzdog ). I wanted to let visitors from my channels know it's my product, without showing my face or mentioning the channel names. So, I decided to record footage of myself composing a tech note and put it behind the app demo. It resulted in achieving two "wow"s: I think these wows genuinely contribute to understanding the product rather than distracting from it. As you can see above, my basic workflow is to have AI write a plan first. Inkdrop supports note templates , and you can pick the " Implementation plan " template to replicate the process. So, I prompted like: then, Claude Code filled out the template via the MCP server . After reviewing the plan, said: then, commit it, and: Claude Code regularly updated the plan note as it discovered unexpected issues and new decisions. I didn't paste code into the chat input. Instead, I constantly pointed at the desktop app's source, Storybook stories, and docs: When I found a bug where the demo frame was broken on smaller screens, I reported it with three things: where it happens, what I observed, and my guess. For example: I still read source code in agentic coding, and I often find weird code structures. So, I ask questions like: When Claude Code added a dirty duplicate function or workaround, I said: When starting something new, I deliberately under-specified and asked for a static skeleton first: This step lets me visually check what should come next and come up with new ideas. Here is the end result: That's it! I hope it's helpful for designing your product page with AI. A demo with real UI components that showcases the look and feel A real editor component that lets you actually try it right on the webpage Wow, the editor syncs with the background footage! Wow, and I can actually try the app here!

0 views
fLaMEd fury Yesterday

Skiing in Tūroa

What’s going on, Internet? This is a later post. It’s been a minute since I’ve been skiing . We left the kids at home with their aunty and headed south to Ohakune. Was made aware before the trip that Ohakune’s main water pipe was recently damaged during an earthquake. Repairs were being made but this weekend the township was without water. Restaurants had to close, hotels and motels had to close and cancel reservations. We wanted to stay at the Powderhorn this trip for two nights, but the Monday night was fully booked so we couldn’t. Ended up working in our favour as they had to cancell all bookings. We ended up in a rental house that was owned and managed by a local couple. No running water, but we were able to flush the toilets with water from the spa pool and was hands etc with bottled water supplied by the council. The view of the mountain from the rental house. Not much was open in town for food but a few of the takeaways were open so we were able to get a decent feed. Weather wasn’t looking too flash for Monday up the mountain but it held out in the end. Sure visibility was rubbish off and on during the day, it snowed around lunch time, a great time to take a break, but the snow was pretty good up there. Like I mentioned, it had been a while (three years) since my last time up the mountain, I went up the top of the Movenpick chair lift and had a rough time getting down. The visibility was horrible up there for the first run. Visibility for the first run. I made it down to the top of the Park Lane chairlift and spent the morning at the Wintergarden tow rope and it’s gentle slop getting familiar with the skiis again. The view from the Wintergarden, tow rope on the right. After lunch I head back up to the top of Park Lane and spent the next couple hours heading down the Park Lane chairlift. Once I was confident with that run I headed back up to the top of the Movenpick. The first part of the slope was a bit of a challenge but I struggled through and made it back to the top of Parklane to finish the run. The base of the mountain, with the Movenpick and Parklane chairlifts heading up. One day up the mountain is not enough. Next trip I want to spend at least three days on the slopes, a couple hours of private lessons would do me well. Hey, thanks for reading this post in your feed reader! Want to chat? Reply by email or add me on XMPP , or send a webmention . Check out the posts archive on the website.

0 views
Unsung Yesterday

“…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 Yesterday

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
Jason Tucker Yesterday

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
ava's blog Yesterday

notebook 15

Haven't posted since March. These selected pages range from April to September. Published 06 Sep, 2026

0 views
マリウス 2 days ago

s/Neovim/Helix/g

A while ago I decided that I’d like to challenge my muscle-memory (and by that my brain) a little by pushing it out of its comfort zone. Last time I did that was back in 2024 with the switch from my endgame M60 to the Corne v3 and ultimately to the Kunai , which required me to re-learn to type, in a way. I remember how dreadfully inconvenient the experience had been in the first days/weeks, but over two years down the line I’m glad I made the move, and I feel like it has definitely improved my typing and my abilities, let alone my wrist posture. This time, I decided to challenge myself to switch away from the editor that I migrated to five years ago , Neovim, to Helix , the post-modern text editor . I had wanted to do this for a long time, and it just so happened that yet another troubling story unfolded within the Neovim community that had once again put the unprofessionalism of the core team on full display. And after years of the project being infiltrated and virtually taken over by bullshitters content creators , I decided that it was about time to move along and start this challenge. Despite many people in the community channel seemingly being loyal members of the Church of Emacs , I didn’t want to go back to an editor that required a GUI, because I enjoy the ability to fire up the exact same editor on any (remote) host (via Mosh ) and feel at home right away. After all that was one of the reasons that pushed me to leave the impeccable editor that is Sublime Text . Note: I know that there’s a TUI version of Emacs, but from what I read online, as well as in the community channel, it is inferior to GUI Emacs and people only use it when they have no other choice. For remote editing it appears that TRAMP is the way to go in Emacs. Anyhow, with Emacs being predominantly GUI-based, it didn’t quite check the boxes for me. I wanted something that is primarily a text editor and not an almost complete operating system. In addition, while I was never particularly fond of Neovim’s Lua, I’m even less so of Lisp, which would have added another layer of reluctance to the whole challenge. After scouring the web for a capable alternative to Vim that would ideally be written in Zig, I ended up with two options, neither of which is Zig-based though: Kakoune and Helix . Kakoune seemed like a very good candidate for something that is similar in spirit to Vim but offers a handful of differentiating features that would make it an interesting pick. However, because it is so similar to Vim, it also has similar requirements with regard to its configuration, making the ramp-up curve relatively steep. For example, just like Vim/Neovim requires you to spend a few hours/days/weeks to properly and thoroughly configure language servers, Kakoune, too, ships those as an external plugin that runs alongside the process and is in fact not even written on top of the same C++ stack that Kakoune uses, but in Rust. Kakoune is practically the same assembly of little bits and pieces that Vim/Neovim had always been, which comes with the same benefits, like endless extensibility, but also with the exact same drawbacks with regard to configuration complexity, breaking changes/incompatibilities, and a noticeable hit in terms of efficiency. My final Neovim config wasn’t as bad as what setups like LazyVim, LunarVim, SpaceVim, et al. usually deliver, but as soon as I was working with large files I could definitely feel the editor and, more precisely, its plugins, putting some load on my system. Hence I found the value proposition of Helix very interesting. One editor, one binary, with complex features like language-specific autocompletion and syntax highlighting built right into the core. Not only does this mean that the efficiency aspect might be noticeably better ( spoiler: it is! ), but also that it doesn’t take me hours/days/weeks to set up what I would call absolute basics in 2026. The only true downside of Helix seemed to be that it’s a Rust project, meaning that compiling takes almost the same time as building the Linux kernel, and that it might be yet another project that’s one wrong pronoun shy of falling apart. Nevertheless, I caved in to the curiosity and decided to try Helix. Both of the things that I had to unlearn first come from the same design decision, namely that Helix puts the selection before the action, so that every motion produces a selection and every command works on whatever is selected at that moment. In Vim I press and the editor waits for a motion that tells it how much to delete, while in Helix deletes what is already selected, and because a resting cursor is a selection of exactly one character, cuts a single character. There is no , because is not an operator that takes a motion. Instead, removing a line is to select it and to delete it. The same principle explains why the cursor moves to the next line when I press at the end of the current one. Helix treats a buffer as one sequence of characters instead of a list of lines, and moving right asks for the next grapheme boundary in that sequence, which after the last character of a line is the line break itself, and after that the first character of the line below. Vim stops at the last character unless is configured otherwise, but Helix has no equivalent option to switch the behavior off. What also follows from the selection-first model is that multiple cursors are built-in. selects the entire file, prompts for a regular expression and turns every match inside the current selection into its own cursor, copies the selection to the next line, splits a selection on line breaks and reverts back to a single cursor. In Neovim I would have renamed something using , but in Helix it is , , the pattern, and then and the new text, with every occurrence visible and editable while I type. Frankly I didn’t mind the way Neovim did it, but Helix’ approach is definitely more visual . Speaking of which, Helix ships by default with another visual feature that makes discoverability a lot better. In Neovim I had to manually configure the plugin for that, but in Helix pressing a prefix key automatically shows an infobox listing every continuation together with a description of what it does. Also, completes typable commands and shows their documentation and their flags while I type, and opens an interactive introduction that ships inside the binary. The list of things that work without configuration in Helix is frankly longer than I expected. The that ships with 25.07 defines 342 languages, pulls in 303 tree-sitter grammars and preconfigures language servers for 189 of those languages. Helix also supports files. Syntax highlighting, autocompletion, diagnostics, fuzzy pickers, a git diff gutter and a debug adapter client are all there without a single line of configuration, where my Neovim setup needed three dozen plugs (that would break at least once a quarter) to support roughly the same feature set. And it’s not like Helix’ configuration is some interpreted Lua glued on top of Vimscript. It is a set of easy to read and understand TOML files, and consists of a , a and, optionally, a . The Neovim setup I linked above was 35 plugins and 1,787 lines of Lua across 21 files, plus another 678 lines for my Netrunner theme, which, reading it, makes it even more absurd. What replaced it is a 65-line , a that includes everything upstream has plus a few overrides, and a 264-line , which is my own theme. And let’s not forget that each of those 35 Neovim plug repositories is maintained by someone else and can change at any time, and each of them could hit me with malware at any moment. The contains the general editor configuration, as well as the custom keybindings. Those can be nested, they can call typable commands, and when they start with they replay a key sequence, which means that the simple but stubborn muscle-memory can be satisfied, at least in some cases: Note: Commands and key sequences sadly cannot be mixed, which means that a binding like is not possible. Whatever feature the core does not implement is left to the shell, for which can be used to pipe the selection through a command and replace it with the output. Similarly, can be used to insert the output of a command before the selection, and can be used to only keep the selections for which a command exits with a zero status. can sort a block of lines, can align a table, and can reflow a paragraph. And as with most command line tools, you can use to suspend the editor and type to bring it back. Speaking of muscle-memory: There is a fork called Evil Helix that restores the Vim keymap for people who want the new core without having to re-learn the motions and commands. While I understand the appeal, installing it would have defeated the point of the exercise for me. However, if you’re on the lookout for an alternative editor to escape the rollercoaster that the Vim ecosystems have become, this might be the thing for you. To put the efficiency claim that I mentioned earlier into actual numbers, I built Markdown files by concatenating the last 180 posts of this site and cutting the result down to 100 KB, 1 MB, 5 MB and 25 MB, the largest of which is 543,015 lines. I ran Neovim with my old configuration, with all 35 plugins and the 70 tree-sitter parsers compiled. Both editors ran on the same 8-vCPU/16GB RAM KVM virtual machine that I used to simulate a resource-strapped environment on my Intel Core Ultra X7 368H . Both used a terminal window of 50 by 200 characters, and every number is the median of three runs after a warm-up. First screen describes the point at which the initial paint completed, and redraws done is when the editor stopped writing to the terminal. As you can see, Neovim paints its first full screen roughly five times faster, in 0.12s against 0.62s on the 1 MB file, because Helix appears to read and parse the whole file before it draws anything. Neovim, instead, draws immediately and keeps working in the background. So much for my assumption that the Rust binary would win the efficiency duel, huh? Well, not so fast, cowboy! As you can see in the next columns everything after that initial load/first paint goes the opposite way. Opening the 5 MB file costs Neovim 300 MB of resident memory and 2.9 seconds of CPU, against 85 MB and 0.6 seconds for Helix, and at 25 MB it is 329 MB and 4.1 seconds against 111 MB and 0.7 seconds. Helix stays close to flat across a 250-fold range in file size, while Neovim’s cost grows with every step. As for the syntax parser, both editors have a deadline and drop highlighting when it is missed, which I measured by counting the distinct foreground colors on a rendered screen whose content is identical in every file. Helix highlights reliably up to about 1.02 MB, intermittently up to 1.12 MB and not at all above 1.13 MB, while Neovim is solid at 2 to 3 MB but stops at 4 MB. That is also why Neovim finishes redrawing the 5 MB file faster than the 1 MB one, since past its own deadline there is a lot less left to do. On the 100 KB file, where both editors highlight the entire screen, Helix needs 38 MB against 65 MB and 0.5 seconds of CPU against 0.8. Jumping to the last line redraws consistently in about 43 ms in Helix, and between 21 and 111 ms in Neovim, so neither of them is really sluggish at that, but Neovim can, at times, take almost three times the time to redraw. My of six languages in Neovim, which I had assumed to be one of the expensive parts, doesn’t really cost anything, as Neovim with and without spell checking is within the presented numbers. Many lines are one problem, however a single line of extreme width is a very different one, and it is one of the things that would normally bring Vim down to its knees. I joined the same 180 posts into one line of 5,201,084 characters, 5 MB in total, and ran the same session in both editors: search for , step through the matches, replace all of them with , jump to the last character with and to the first one with , and then insert single characters at the very beginning. Every number is the time from the keystroke until the editor stopped drawing and stopped using CPU. Helix opens that file almost nine times faster and finds the pattern quicker. Neovim, however, beats Helix in the replacement task by a wide margin, because is a single operation while Helix builds 471 cursors and then edits every one of them. Inserting a single character at the beginning of that line costs Helix about a quarter of a second. Neovim pegs one core at 100 percent and allocates roughly 25 MB of memory per second while drawing absolutely nothing, and it kept doing that for the two minutes after which I cut the measurement off. By that time the session was at 9.1 GB of resident memory, but the character still hadn’t made it into the buffer. To be fair to Neovim, this one is not its direct fault, though. The exact same file and the exact same keystroke in inserts the character in well under a second and drops back to zero CPU at 150 MB, so what melts down here is my configuration, which is most likely the combination of spell checking and tree-sitter on a line that is five million characters wide. If you think that what I wrote about Helix so far reads too good to be true, and that there’s definitely a catch somewhere , then your intuition still works. For example, one thing I run into every day is the front matter of the posts on this site. In Neovim, a small plugin rewrote the field of the Markdown file on every write, and set the of a post I had just created. Helix, on the other hand, does not have a built-in feature like that, nor does it offer a user-facing API to custom build this. The only thing that runs on write is the that is configured for the language, which takes the buffer on standard input and writes it back on standard output. A front matter updater therefore has to be a filter over the entire document, and it has to coexist with whatever formatter is already configured for Markdown. Inserting a timestamp by hand is , which is fine for the of a new post, but definitely more cumbersome for a that is supposed to be maintained without me thinking about it. Another example concerns the keybindings. A binding made of two subsequent combinations works, so a section that contains gives me a chord. Pressing the first half pops up the infobox that shows the second half. What doesn’t work, however, is having the same key bound on its own and as the prefix of a longer sequence, which is what I had in Neovim: The keymap’s nodes can be either commands or submaps, but Helix doesn’t support a timeout that would disambiguate the two the way Vim’s does. Hence a key is either a binding or a prefix, and for quitting next to for quitting without saving is sadly not something one can express in Helix. A handful of smaller things from Vim have no equivalent either. There are no marks, and the “Marks” issue has been open since September 2021. Getting back to a position therefore requires a search or the jumplist. There is also no persistent undo, which means that closing a buffer discards its history. Two attempts at adding this feature were closed without being merged. There is also no session restore, and repeats only the last insert, not the last change the way it does in Vim, so a delete or a replace can’t be repeated with it. What’s left is to repeat the last motion and to jump to the last modification. Spell checking is another thing that’s missing entirely. Vim and Neovim ship with dictionaries, but Helix has nothing of the sort, so the only way to get my typos underlined is via a language server such as codebook or , configured per language in . Then there’s the Git support, which is basically just the diff gutter. Helix marks added, modified and deleted lines against by default and and jump between the changes, however there is no blame and no staging of individual hunks. and or a second pane can cover for that, but for people who would like to avoid the context switch for adding files or committing, an integration of the most basic Git functions would make sense. Speaking of files, the file picker gained a directory-browsing mode in 25.07 ( #11285 ), but a persistent tree sidebar of the neo-tree kind still doesn’t exist in Helix. I know that you’re not supposed to navigate via the sidebar and that fuzzy-finding and buffers are theoretically superior, but I also know how a big project structure can sometimes become increasingly difficult to efficiently fuzzy-find through. Lastly, the plugin system. There is no plugin system. Steel, the Scheme dialect that might become one, has been a draft pull request ( #8675 ) since October 2023, and it has 491 commits and 334 comments without being merged. It appears that in various forks it is already in use, which makes it even weirder. But that is essentially why there are no user-defined commands, no scripting and no ecosystem. However, it is also why nothing in my configuration has broken since the day I wrote it. Both things are consequences of the lack of plugins, and I am not sure yet which of the two I value more. Now to some more essential things that I would argue are crucial for many engineers and developers, but that are nowhere to be found in Helix. Amongst the most important features is continuous hard wrap. Helix does soft-wrap, at when is set, and it does hard-wrap on demand with , which rewraps the selected lines to the width given as an argument or to the configured . What it doesn’t do is wrap while I type, the way together with does in Vim, and the issue has been open since April 2022. Selecting a paragraph and calling from a keybinding works but it is obviously very cumbersome. What I have settled on for now is an on-save formatter such as or , which however means that the formatter rewraps the whole file including the paragraphs that I never touched, which turns a three-line edit into a diff over an entire file. Another essential that’s missing is the file watcher. Helix doesn’t watch the files it has open. When a buffer changes on disk, after a , a rebase, a formatter that ran in a different window or a sync over Syncthing, the editor keeps showing the old content until I run or . It does at least reject the write with , so nothing gets overwritten by accident, however all of the reloading is manual, while Neovim covers this with and . The pull request that implements file watching was opened in October 2025 and is still open. Then, there is the overtype mode, which also doesn’t exist at all. In Vim, replaces characters as I type until I press escape, which is how a fixed-width table, a box drawing or a column-aligned configuration file gets edited without pushing everything to the right. Helix uses to replace every character of the selection with a single one and to paste over the selection from a register. However, neither of them is sticky. The request has been open since February 2023 and a pull request implementing it since July 2025, and until that is merged, the workaround is selecting exactly as many characters as I am about to type and then replacing them. Speaking of selections, a line-wise selection mode is missing as well. Vim’s selects whole lines and and then extend the selection in both directions, while Helix has to extend the selection one line downward, to snap whatever is selected to whole lines and to shrink it back to them, none of which extends upward. Binding covers most of it, however extending upward still means entering select mode, moving with and pressing to put the selection back on line boundaries. Lastly, code folding doesn’t exist, and it is the one complaint I came across most often while reading through the issue tracker, although to be fair I personally don’t use this feature as extensively, so it’s not as much of a big deal for me. The issue has been open since March 2022 and has 48 comments, and a pull request has been open since October 2025. Vim has been folding since version 6.0 in 2001 and Neovim added tree-sitter-based folds on top of that, while Helix offers neither automatic nor manual folds, which for navigating a long file leaves the jumplist, the symbol picker and scrolling. With all that said, I’m going to stick to Helix for the time being, despite its bad and ugly parts. It is an overall solid piece of software, it has a relatively big community around it, and it is available on every platform that I care about. On top of that, it uses a fraction of the memory and CPU of the Neovim setup that I had in place before. It is also far easier to build on my Gentoo machine than Neovim. However, I do hope that Helix will continue to evolve into something that is able to offer slightly more flexibility than it does today, without compromising too much on its approach of delivering an editor with batteries included. I, too, hope that we’re not going to witness similarly poor stewardship as has been the case with Neovim, and that the Helix project can continue living somewhat in the shadows, overlooked by most content creators .

0 views
Manuel Moreale 2 days ago

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 2 days ago

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 2 days ago

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 2 days ago

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 2 days ago

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 3 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 3 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 3 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 3 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