HN user

vlowther

197 karma
Posts0
Comments102
View on HN
No posts found.

It also isn't a unique thing. See (for example) the entire history of, well, pretty much any country. There is a reason Utopia literally means "no place".

* Genocide of the natives? Literally all countries in the Americas, for starters. * Slavery of captives from Africa? Pretty much everyone with colonies in and around the Caribbean was guilty of that too. * Multiple unnecessary wars that have killed millions of people? That encompasses more or less all of European history.

By all means, criticize Palantir. But don't pretend US history has anything in particular that would set up the prerequisites for it to exist.

MBP M5 Max. 128GB ram. oMLX. unsloth-Qwen3-Coder-Next-mlx-8bit. opencode with the telemetry stripped out. This seems to be the sweet spot for now for my local dev. Helps me to not accidentally blow through $100 in Claude tokens in a day when exploring different performance tradeoffs the backend of my $DAYJOB codebase.

In my case, "background writes" literally means "do the io.WriteAt for this fixed-size buffer in another goroutine so that the one servicing the blob write can get on with encryption / CRC calculation / stuffing the resulting byte stream into fixed-size buffers". Handling it that way lets me keep the IO to the kernel as saturated as possible without the added schedule + mutex overhead sending stuff thru a channel incurs, while still keeping a hard upper bound on IO in flight (max semaphore weight) and write buffer allocations (sync.Pool). My fixed-size buffers are 32k, and it is a net win even there.

My usecase was building an append-only blob store with mandatory encryption, but using a semaphore + direct goroutine calls to limit background write concurrency instead of a channel + dedicated writer goroutines was a net win across a wide variety of write sizes and max concurrent inflight writes. It is interesting that frankenphp + caddy came up with almost the same conclusion despite vastly different work being done.

If the performance charts are to be believed, this has uniformly worse performance in fetching and iterating over items than a boring old b-tree, which makes it a total nonstarter for most workloads I care about.

It is also sort of ironic that one of the key performance callouts is a lack of pointer chasing, but the Go implementation is a slice that contains other slices without making sure they are using the same backing array, which is just pointer chasing under the hood. I have not examined the code closely, but it is also probably what let them get rid of the black array as a performance optimization.

Only if you are doing in-place updates. If append-only datastores are your jam, writes via mmap are Just Fine:

  $ go test -v
  === RUN   TestChunkOps
      chunk_test.go:26: Checking basic persistence and Store expansion.
      chunk_test.go:74: Checking close and reopen read-only
      chunk_test.go:106: Checking that readonly blocks write ops
      chunk_test.go:116: Checking Clear
      chunk_test.go:175: Checking interrupted write
  --- PASS: TestChunkOps (0.06s)
  === RUN   TestEncWriteSpeed
      chunk_test.go:246: Wrote 1443 MB/s
      chunk_test.go:264: Read 5525.418751 MB/s
  --- PASS: TestEncWriteSpeed (1.42s)
  === RUN   TestPlaintextWriteSpeed
      chunk_test.go:301: Wrote 1693 MB/s
      chunk_test.go:319: Read 10528.744206 MB/s
  --- PASS: TestPlaintextWriteSpeed (1.36s)
  PASS

That sort of thing is why I wrote my own DHCPv4 server that is directly integrated with the core product at $DAYJOB. 10 years ago. Having the DHCP server determine how to handle PXE requests straight from the machine database made my life so much simpler.

CoW adaptive radix trees are the entire basis of $WORK's in-memory database -- we use them to store everything, then use boring old btrees to handle sorting data in arbitrary ways. A nice, performant persistent CoW radix tree would be a nice thing to have in my back pocket.

JSON Patch 2 years ago

$WORK project heavily utilizes the test op to enable atomic updates to objects across multiple competing clients. It winds up working really well for that purpose.

Fast B-Trees 2 years ago

I reach for adaptive radix trees over b-trees when I have keys and don't need to have arbitrary sort orderings these days. They are just that much more CPU and memory efficient.

What is the hassle with maintaining hydraulic disk brakes on a bicycle? On my motorcycle, you replace the brake fluid and inspect the rotors to make sure they are in tolerance every couple of years, check the pads for wear, the lines for damage and replace if needed every oil change, and that is pretty much it. I would imagine that bicycle hydraulics are even easier to maintain, if only because they don't have nearly as much energy to dissipate as motorcycle brakes do.

Can't share my references with you directly, the implementation I wrote is closed-source and is heavily intermingled with other internal bits. But I can provide examples:

1. syscall.Iovec is a struct that the writev() systemcall uses. You build it up something like this:

    func b2iov(bs [][]byte) []syscall.Iovec {
        res := []syscall.Iovec{}
        for i := range bs {
            res = append(res, syscall.Iovec{Base: &bs[i][0], Len: uint64(len(bs[i])}
        }
        return res
    }
Then, once you are ready to write:
    func write(fi *os.File, iov []syscall.Iovec, at int64) (written int64, err error) {
        if _, err = fi.Seek(at, io.SeekStart); err != nil {
            return
        }
        wr, _, errno := syscall.Syscall(syscall.SYS_WRITEV, fi.Fd(), uintptr(unsafe.Pointer(&iov[0])), uintptr(len(iov)))
        if errno != 0 {
            err = errno
            return
        }
        written = int64(wr)
        err = fi.Sync()
        return
    }
These are not tested and omit some more advanced error checking, but the basic idea is that you use the writev() system call (POSIX standard, so if you want to target Windows you will need to find its equivalent) to do the heavy lifting of writing a bunch of byte buffers as a single unit to the backing file at a known location.

2. Yeah, I just zero-filled a new file using the fallocate as well.

3. I handled max buffer age by feeding writes to the WAL using a channel, then the main reader loop for that channel select on both the main channel and a time.Timer.C channel. Get clever with the Reset() method on that timer and you can implement whatever timeout scheme you like.

4. No, it is not needed, but my WAL implementation boiled down to a bunch of byte buffers protected by a rolling CRC64, and for me just mmap'ing the whole file into a big slice and sanity-checking the rolling crcs along with other metadata was easier and faster that way.

Having written one of these, a few optimizations will go a long way:

1. syscall.Iovec allows you to build up multiple batches semi independently and then write them all in a single syscall and sync the file with the next one. It is a good basis for allowing multiple pending writes to proceed in independent go routines and have another one have all the responsibility for flushing data.

2. It is better to use larger preallocated files than a bunch of smaller ones, along with batching, fixed size headers and padding write blocks to a known size. 16 megabytes per wal and a 128 byte padding worked well for me.

3. Batching writes until they reach a max buffer size and/or a max buffer age can also massively increase throughput. 1 megabyte max pending write or 50 ms time passed worked pretty well for me for batching and throughput to start with, then dynamically tuning the last bound to the rolling average of the time the last 16 write+sync operations (and a hard upper bound to deal with 99th percentile latency badness) worked better. Bounded channels and a little clever math makes parallelizing all of this pretty seamless.

4. Mmap'ing the wals makes consistency checking and byte level fiddling much easier on replay. No need to seek or use a buffered reader, just use slice math and copy() or append() to pull out what you need.