Tuesday, October 13, 2026

Small String Optimization in Go

Go strings are "fat" pointers consisting of a pointer and a length. On 64 bit systems this makes them 16 bytes. This works well for most things.

Go interfaces (think virtual methods) are also "fat" pointers consisting of a type and a pointer.

The problem comes when you combine the two. When you store a string in an interface, the string fat pointer (16 bytes) can't be stored in the pointer part of an interface (8 bytes) so the string header (fat pointer) has to be on the heap and a pointer to it in the interface.

This has several downsides:

  • extra allocation leading to a larger heap and more garbage collection work
  • extra pointer following leading to potential CPU cache misses
  • the string header contains a pointer so it requires scanning by the garbage collector (as opposed to "noscan" allocations like the actual string data, which do not contain pointers)

Ideally, you'd store short strings right in the 8 pointer bytes of the interface (a tagged pointer). Go doesn't allow that kind of low level trick. You can encode some information as a pointer to uninitialized memory, and gSuneido does that for small integers.

The next best option is to have the interface point directly to the string data, perhaps in a struct like:

struct {
    size byte
    data [15]byte
}

This makes it the same size as the Go string header, but that's not a requirement. It could be smaller or larger.

I added some quick instrumentation to Suneido and found roughly 50% of strings were under 16 bytes. I thought it would be more. 70% were under 24 bytes, and 83% were under 32 bytes.

The tradeoff, of course, is wasted space. If you make the struct 32 bytes and only store 5 bytes, you're wasting more space than standard Go (16 header + 5 data = 21).

I pondered this. You could have multiple struct sizes. But that adds complexity and slows down the code. In C you could allocate the necessary size and still treat it as a struct. You could probably do something like that in Go but you'd have to use unsafe code and it would be ugly.

Wait a minute, why do we need a struct at all? Squint a little, and this is just a sequence of bytes with the size in the first byte. We can just store *byte in the interface, pointing to what amounts to a "Pascal string" with a leading size. This means we can have strings up to 255 bytes long, and at the same time have no wastage on tiny strings. This is what I implemented.

There is a catch to using *byte - Go doesn't allow methods on a built-in type like byte, so you have to define a new type e.g. type strp byte and then define the methods on *strp.

One subtlety is that when you are starting from a Go string, creating a small string requires allocation and copying. So even though this small string representation can handle up to 255 bytes, the allocation crossover is 15 bytes (plus a size byte = 16). Above this, creating a small string does more allocation than allocating a 16 byte Go string header. There are still advantages to small strings so the optimal threshold is a bit higher. I set it at 23.

One of the benefits of Go strings is that you can get a substring without allocation or copying. You can't do that with a size prefixed string because a substring wouldn't have the necessary size prefix. However, for small strings this is fast and has the advantage that a small substring won't stop a much larger string from being garbage collected.

On the other hand, when concatenating strings, you always have to allocate the result, so you can use the full 255 byte limit for small strings.

Another Go trick that is key to this approach is that we convert back to a Go string with no allocation or copying. We simply use unsafe.String to point to the same bytes (just past the leading size). While this uses the unsafe package, it is actually safe as long as you never modify the bytes in place because Go strings are immutable. In my implementation this use of unsafe is isolated from the bulk of the code and only exposes immutable strings.

Curiously, while AI is familiar with the small string optimization (SSO), it assumed the fixed size struct representation. It saw the benefits of the *byte approach, but it never suggested it. I assume that means it's not a common approach and therefore not in the training data. Granted, classic SSO puts the data in the string header (or even in a tagged pointer), whereas in this case we're moving from two indirections (interface to string header to data) to one indirection (interface to data).

The big question was whether this optimization was worth it. Would the benefits outweigh the extra complexity? The obvious measurable benefit was less allocation. But even there, it's hard to predict the overall effect. A lot of the potential benefits are "diffuse", things like cache misses and garbage collector overhead.

Part of what convinced me to go ahead was that gSuneido already had multiple string types, so I already had a framework for adding another type. Two types to three is a much smaller jump than one to two.

Of course, it turned out to be more work than I thought, but the process of revisiting all the string handling code was beneficial in itself since I found several inconsistencies and minor potential bugs. One thing that helped was that all of the gSuneido string types are efficiently convertible to Go strings with no allocation or copying. That meant operations can always be performed with Go strings. However, that isn't entirely free, since converting back to a gSuneido value (interface) usually involves allocation.

In the end, some simple instrumentation showed 83% small strings which seems pretty good. Some simple benchmarks showed the expected reduction in allocation. And even in Suneido code, with the overhead of byte code interpretation, I saw improvements in speed. Of course, you can make benchmarks that show whatever you want to show. It didn't have a huge impact on overall execution speed. I didn't expect it to since string handling is just one small part of the system. But the results seem positive enough to justify the change.

Saturday, September 05, 2026

Database Stats

I just landed a large commit in gSuneido to add database stats (metadata) and use them for query optimization. (It probably should have been multiple commits, but I kept thinking I almost had it finished. Typically, I was 90% finished for quite a long time!)

A while ago I had implemented several algorithms/data structures to use for this:

The first question is which table columns do you gather stats for? All of them would be too much data and much of it unused. We only need stats for commonly used columns on large tables. So the first step is to use an SS sketch weighted by table size to track these "hot" columns. This can be accumulated in memory and periodically written to disk. It could go in the database itself, but I chose to write it to a text file to keep it separate.

The next question is when do you actually collect the data stats? The obvious time for Suneido is during compaction (usually weekly) which has to process all the data anyway. So it reads the text file of hot columns and for each one collects HLL, KLL, and SS.

To avoid overhead (and complexity) I chose not to try to update the stats with data updates. This means the stats can get slightly out of date, but generally one week's changes do not substantially affect the statistics on big tables.

Once we have the stats we can actually do the functional part of this whole setup - use them to help with query optimization, in particular to help choose indexes for WHERE clauses. I ended up overhauling this part of the code so it could cleanly incorporate the extra information.

One thing that wasn't clear at first was how you actually use the HLL, KLL, and SS data to estimate a fraction of the data. This depends on whether the WHERE clause is a single value or whether it's a range. Ranges are simpler - they just use the quantiles. I collect 50 quantiles to give 2% precision. So, for example, if a range covers 3 quantiles, it's 6% of the data.

Single values are a little more involved. First you check SS to see if it's one of the known most common values. If so, that gives you the approximate count and therefore the fraction of the table. If it's not a common value, we calculate the "tail" after subtracting the common values and assume it's 1/N of the tail, where N is the total number of distinct values (from HLL) minus the number of heavy hitters (from SS).

Despite this age of AI, I ended up writing quite a bit of this code the old-fashioned way, by hand. Mostly that was because I was unclear how parts of it should work, which meant I couldn't tell an AI what I wanted. I reverted to working it out as I wrote and rewrote the code. Parts where it was clear what was needed (and explainable) I got AI to write. Even then I refactored most of the AI-written code.

As usual the code is on Github.

Friday, July 17, 2026

Programmers

Sometimes AI is so much like a human programmer.

First they tell you something is near impossible.

You point out a possible way to do it.

Then they say it might be possible but it's really hard and complicated.

You point out a simple way to do it.

Finally they admit that, ok, it's actually easy.

I guess they won't be taking my job this month :-)

Tuesday, November 18, 2025

Roaring Bitmaps

I like useful algorithms I can implement (at least the basics) in a few hours.

Bitmaps are a useful tool for a variety of applications. But they can be large. I was thinking of using them to intersect sets of database records. Even if I only used one bit per 16 bytes, a 32 gb database would be 256 mb (log2: 35 bits - 7 bits = 28 bits) - too big.

People have tried different approaches to shrink bitmaps. Run length encoding works fairly well but is slower to access.

Roaring bitmaps take advantage of several different approaches. The space is divided into 64 K bit blocks (8 K bytes). At the top level there is a sorted expanding list of blocks (usually relatively small) which can be binary searched. The values within a given block are 16 bit. Each block is stored in one of several different representations. If there are less than 4 K entries (a sparse block) they are stored as a sorted expanding list that can be binary searched. If there are more than 4 K entries (a dense block) then they are stored as a bitmap. An improved version adds a third run length encoded block type. With my usage I don't expect contiguous values so I didn't implement this block type.

Lookups are logarithmic, a binary search to find the block and then either another binary search or a bitmap probe within the block.

There are open source implementations available, but I only needed a subset of features so it was just as easy to write my own. (only 150 lines of code) To reduce allocation, I used a Go sync.Pool to recycle blocks.

The original papers are Better bitmap performance with Roaring bitmaps and Consistently faster and smaller compressed bitmaps with Roaring

Sunday, October 19, 2025

KLL Sketch Quantiles

Recently I was looking for a good way to "characterize" the values in a column of a database table, to help Suneido's query optimizer choose the best index to use. Originally I was thinking of some kind of histogram. While "discussing" alternatives with Gemini, KLL Quantile Sketches came up. Histograms are usually equi-width - each bin represents the same range of values e.g. 0 to 1 out of 10. Quantiles are usually equi-depth - each bin represents the same frequency of values e.g. 1%. With database data you don't usually know the range of values and it's not easy to divide values like strings into equal ranges so quantiles made more sense.

The name "KLL" comes from the authors (Karnin, Lang, and Liberty) of the original paper Optimal Quantile Approximation in Streams.

I have to admit I struggled with understanding KLL Sketches. At first I got Gemini to explain them to me and show me a sample implementation. I thought I understood it until I tried to implement it. Since I was off track, I quickly led Gemini astray and ended up flailing around for longer than I should have. I finally gave up on AI and went back to the original paper. The bulk of the paper is math theory proving that the algorithm worked within specific error bounds. I didn't realy follow the math and I didn't really care about the proof - I was willing to take their word for it. I just wanted to know how to implement it. In the end, the key part was a couple of paragraphs of the paper. The basic idea is pretty simple.

Unfortunately, I didn't find a lot of material on KLL sketches (or its precursor MRL sketches). There isn't even a Wikipedia page! There is an Apache DataSketches library that includes KLL sketches. I didn't look at their code. Trying to figure out basic ideas from a full implementation is hard.

It's easiest to understand by working through several increasingly optimized versions. The first version is just several buffers that hold k values. k determines the accuracy and a common value is 200 which gives 1.65% accuracy. The buffers are known as "compactors".

Incoming values are added to the first compactor. When it gets full, you sort the values, and then taking them in pairs of consecutive values you pick either the even or the odd ones and discard the others. Even or odd is chosen randomly, or randomly first time and alternating thereafter. You end up with half as many values, which you promote to the next compactor. In turn, when the next compactor fills up, you compact it and promote half the values. You add compactors as necessary. Each compactor represents/summarizes twice as many values i.e. its weight doubles.

The first optimization is that as you add levels, the levels closest to the input can be smaller without losing accuracy. The newer values are less important since they represent less of the data. This saves memory and reduces the work of compacting. The size factor is called c and the usual value is 2/3.

In terms of the algorithm, you add a new level of size k and shrink the existing levels. But in terms of implementation, you can't really shrink a memory allocation. You can take a sub-slice but the full size is still there behind the scenes. Instead, I added a new smaller compactor on the input end. The problem with that is that the pre-existing compactors now have the wrong weights. To fix that, I compact each of those previous levels in place, which has the effect of doubling their weight. This approach isn't in the original paper (which is light on implementation) but it seems to retain the correct behavior.

As the compactors get smaller, eventually you reach the minimum size of 2. The next optimization is that all the levels of size 2 can be combined into a "sampler" which randomly selects one value from every 2^n where n is the number of size 2 levels replaced by the sampler.

Querying the result is straightforward - you take the values with their implicit weights and sort them. Then the cumulative weight gives you the quantiles. e.g. 50% of the weight gives you the median.

There are further optimizations that I didn't implement. They seem more theoretical improvements than practical. My simple implementation (< 200 lines of Go) seems to give good performance with low memory requirements. (> 100,000,000 values/second, zero allocation once it reaches the maximum size of about 600 values)

Sunday, September 21, 2025

AI Coding Tools

A few people have asked about what tools I'm using for AI coding so I figured I'd snapshot what I'm using right now. Given how fast AI is changing it will probably be different six months from now. I haven't tested different tools extensively so don't take this as expert advice, just one data point.

For tab completion I've been using Amp Tab which is the tab completion part of Amp which is Sourcegraph's second generation AI coding tool (after Cody). Amp itself is a little too aggressive for my style of programming. I prefer to review changes closely before applying them. Currently Amp Tab is experimental and is still free. My understanding is that it's similar to Cursor, which I haven't tried because I prefer to use standard VSCodium. Even Amp Tab can be a little aggressive for me sometimes. I have to be careful about hitting tab to indent a line since it's liable to go and make changes to my code.

For investigating, reviewing, or writing code I've been using Cline as a VSCode extension. Cline is open source and lets you pick your AI model and provider. I've been using OpenRouter so I can try out different models. You can also use Cline as the provider which I might have done if I'd realized it before I signed up with OpenRouter.

Cline has a "planning" mode which is basically read-only, and an "act" mode where it makes code changes. Even in act mode I require approval for code changes.

As far as models, I started out with Claude Sonnet 3.5 and progressed to 3.7 and now 4. I've also tried a few others like GPT-5 and grok-code-fast-1. There isn't a huge difference, they can all do well or mess up badly but I tend to go back to Claude Sonnet 4 even though it's one of the more expensive ones. The last few months I've been spending about $50 per month on model usage. It's worth it for me, as much for the learning experience as for the actual code produced. If you didn't want to (or couldn't afford to) spend money on it, there are usually free or cheap options. 

For general research I've been using Gemini 2.5 Pro, mostly because it's included with our company Gmail/Google accounts. It works well to research algorithms or data structures.

The big question these days is whether programmers are actually more productive using AI. There have been studies that show that although programmers feel they're more productive, they're actually not. It sounds a bit like multi-tasking. I wouldn't say it's made a huge difference to my productivity. Some types of tasks go quicker, but for others AI can become a big time waster. I would say the quality of my code might be slightly higher from having more tests and more reviews.

Sunday, September 07, 2025

A Priority Queue with sync.Cond

The good news is Claude (Sonnet 4) found a bug for me.

The bad news is that Claude wrote the code that had the bug.

It's not all Claude's fault though. Garbage in, garbage out. When I searched on the internet for an example, I found a post with the same bug. I left a comment explaining the bug, hopefully it'll get corrected.

The history was somewhat amusing as well: (paraphrased)

Me: we could write a priority queue

Claude: that would be complex

Me: but we could encapsulate the complexity in the queue package

Claude: good idea

Me: write a priority queue (followed by the requirements)

Claude: no problem, here you go

The code for a concurrent producer-consumer queue is not that complicated, even with specific priority and ordering constraints.

I also got Claude to write tests and benchmarks and everything seemed to work well. The benchmarks showed it had comparable performance to a Go channel. I changed my code to use the new queue (replacing a simple Go channel) and it worked fine. It even appeared to show the hoped for performance improvements. I got Claude to write more benchmarks to measure performance better. But the benchmarks would hang sometimes. I tried to get Claude to fix it but it just thrashed around rewriting the benchmark different ways. I suspected a deadlock in the priority queue so I wrote a better test for that. It appeared to work fine, until I increased the number of threads to 16 or 32, then it would hang consistently with a deadlock. I gave the simple failing test to Claude and it immediately spotted the bug.

The "obvious" approach is to use a single condition variable (sync.Cond) for the queue. Put and Get wait on the Cond and then signal it. It seems straightforward, and under low concurrency it appears to work. But under high concurrency, it can deadlock. The problem is that Signal only wakes up one goroutine. If the queue is full and it wakes up a producer, or the queue is empty and it wakes up a consumer, it will deadlock.
One solution is to use Broadcast to wake up all waiters but that can be inefficient with many goroutines. The better solution is to use two sync.Cond, one for not-full and one for not-empty. Put waits on not-full and signals not-empty. Get waits on not-empty and signals not-full. I should have read the Wikipedia article instead of trusting AI generated code.

Go's sync.Cond has a bit of a funny story in itself. There is no example for it in the Go documentation. When someone suggested adding an example they were told that sync.Cond is tricky and therefore shouldn't be used and they didn't want to add an example because that would "encourage" people to use it. So it becomes a self fulfilling prophecy that people misuse it. At one point there was even a movement to remove sync.Cond from Go. It seems a little odd to me, since condition variables are a standard concurrency concept. It seems to me a few examples would prevent almost all the misuse.

Here's the priority queue code if you're interested.