Show HN: My Go SQLite driver did poorly on a benchmark, so I fixed it (github.com)
https://github.com/ncruces/go-sqlite3 was doing poorly on this benchmark that was posted yesterday to HackerNews [1].
With the help of some pprof, I was able to trace it to a serious performance regression introduced two weeks ago, and come up with the fix (happy to field questions, if you're interested in the nitty gritty).
It's not the fastest driver around, but it's no longer the slowest: comfortably middle of the pack. It's based on a WASM build of SQLite, and thanks to https://wazero.io doesn't need CGO.
60 comments
[ 4.2 ms ] story [ 123 ms ] thread> Sqinn-Go is a Go (Golang) library for accessing SQLite databases without cgo. It uses Sqinn https://github.com/cvilsmeier/sqinn under the hood. It starts Sqinn as a child process (os/exec) and communicates with Sqinn over stdin/stdout/stderr. The Sqinn child process then does the SQLite work.
> If you want SQLite but do not want cgo, Sqinn-Go can be a solution.
This seems pretty clear to me about what's happening. It makes an OS call to a third-party executable (sqinn), then pipes the result from stdout back into the Go code. The advantage is you don't have to compile the C code alongside your Go code.
Honestly, I don't really know how much more clear the author could be. I guess if you don't know what stdin, stdout, and stderr are it might be confusing? It's hard to imagine a programmer who is interested in this library but isn't immediately familiar with those concepts.
So, my bindings wrap a WASM build of SQLite. The WASM module exports functions (like sqlite3_exec) for Go to call, and imports Go functions (like go_full_pathname) for WASM to call.
To call these functions you need to get “function pointers” to them by “name”, and this is actually a very slow process, orders of magnitude slower than actually calling the function.
So I used to setup all the function pointers at instantiation time, and then reuse them for every call.
But this is not supported, and fails, when used reentrantly: Go calls sqlite3_step, which in turn calls some Go code (e.g. to implement a virtual table) which then calls sqlite3_step again (the virtual table reads data from another table). Only happens when the same function is called: stack pointers are kept in a structure that gets reused, so the stack gets corrupted.
The simple fix is to never reuse these structures, but that makes everything significantly slower. So I needed to cache them, but not allow concurrent reuse.
The caching I was already doing (hash based) was not good enough. So I switched to a 4x wider PLRU bit cache. Hit rates are now over 99%, the cache is itself fast, and lazily filling it improves startup times.
Fixing that hot path surfaced others, also recent features. Doing badly on a benchmark is a great way to improve!
PS: wazero is a great piece of software, tested and fuzzed to death. They delayed releasing their next compiler engine for 3 months because of a stack corruption bug that only happened rarely in fuzzing (because of an interaction with signal handlers used by the Go fuzzer). They were all over my bug, because the result was also stack corruption, on a compiler that had been tested to death and passed. But my bug was due to API misuse, not any wazero fault.
Personally, I would add ~2 sentences in the order I outlined, and then I would reorient the list (currently library-first) in a use-case first arrangement like you mentioned.
I mean, realistically my git repos are a mess, so who am I to talk? But this one looks promising enough such that it's worth offering a bit of feedback.
4. Someone following a link from HN
I'm often confused when clicking through from HN and can't figure out from the website or README what the project is doing.
Keeps the signal to noise ratio higher.
You can (e.g.) easily whip up a small CLI that embeds an SQLite database in the binary [1], cross compile this to all the platforms Go supports [2], copy one file, and be reasonably sure it'll run in any of them.
This is an example of embedding a database in a binary (with go:embed), and opening it for reading/writing in memory. You can then save the modified copy to disk using the backup API:
[1]: https://pkg.go.dev/github.com/ncruces/go-sqlite3/vfs/memdb#e...
[2]: https://gist.github.com/asukakenji/f15ba7e588ac42795f421b48b...
It seems to me it's not about would C sqlite run or not on that platform but that there is still some other convenience, and that's what I'd still like to learn, as I'm sure ncruces does solve some specific problem.
To cross compile from your platform A to that platform B, you need a cross compiler, which may be even harder to come by.
If you're working on a Go project, you already have a Go compiler that can do this (cross compilation) very easily, out of the box.
But if you add a C dependency, you also need a C cross compiler. SQLite is easy to configure, and very portable, but this still adds some friction.
Then if you want to setup CI, you need all this infra in CI. If you want to automate releases for a bunch of platforms, you need all of it in your release process.
I'm not saying it's a huge pain but it's enough that some projects avoid cgo as a matter of principle.
I'm surely aware of the fact that C cross-compilation is often very clumsy implemented. But I also have to mention that there is a project that also tries to solve that too, while also creating a whole new language:
https://zig.news/kristoff/building-sqlite-with-cgo-for-every...
We ran into this recently as our CI system is running a newer OS than we currently deploy to - and disabling CGO was an easy way to sidestep this requirement
Not sure SQLite is tested in this configuration, for the fans of “this didn't pass TH3 so we can't trust it at all.”
—-
1: https://dave.cheney.net/2016/01/18/cgo-is-not-go
One question I have is, is there a strategy to stop the performance from accidentally regressing in the future? Seems like it might not be a bad idea to include some benchmarks in the repository if you don't already.
Also, personally, I am interested in the nitty gritty of why it was slow and how you fixed it, so if you write it anywhere in this thread at least I will definitely read it through.
Heads up about the modernc library, it has been stuck on an old version of sqlite for several months [1]. It seems like maintainer time is the limiting factor [2]. There has been a call to arms on that issue page, the maintainer is looking for help, but it looks like not much has arrived. It seems like it might trace back to blockers in the C-to-Go compiler.
It's a major undertaking and a very impressive piece of work, but I'm not surprised it's a struggle when big roadblocks get hit. I hope they find a way to progress, but I'm very relieved to be seeing some CGo-free alternatives like ncruces/go-sqlite3 emerging. I'm going to give it a try for sure and see if I can live with the compromises.
Squinn-go looks very compelling too, but I don't like that it requires the squinn binary to already be installed on a user's machine, I think that gives with one hand and takes with the other: sure, I get to avoid CGo, but I also lose the turnkey, single-command install, static build benefits Go brings out of the box.
Seconding the point about nitty gritty, I'd read it for sure too!
About my driver, the major compromise to be aware of is that WAL mode support is limited. No tiptoeing here: I call that caveat out in the front page.
My driver reimplements the entire SQLite OS Interface (VFS) in pure Go. This makes it very portable, but it's also a risk: it's definitely not as extensively tested as SQLite's. And the WASM sandboxing model makes shared memory a hard problem to solve, which prevents WAL mode from working.
I have some ideas for work arounds, but: they're hard to implement, and hard to "prove" correct. Idea 1 is a VFS for WAL where SQLite believes it's using memory journal mode, but the wrapper uses the standard WAL format to persist disk. It's a lot of work, but standard SQLite tools can recover a crashed DB. Idea 2 is for the WASM host to simulate shared memory through copying. This should work (there are explicit locks and memory barriers to use as copy points), but it's hard to "prove" correct (without bothering SQLite developers to death with details).
I had some hope that the work being done in libsql to virtualize the WAL would help, but their API for this is just terrible. They simply extracted and exposed internal SQLite stuff, but have no documentation, no usage examples, or even any public VFS implementation using these APIs. And I honestly don't trust their ability to maintain a quality fork. An API that's not dogfooded nor documented is not an API.
https://github.com/ncruces/go-sqlite3/blob/main/.github/work...
I do apply some light patches to SQLite, but so far they've always cleanly applied, and I can produce a new release within hours of being notified of SQLite releases.
The commits implementing the new function cache are these two: https://github.com/ncruces/go-sqlite3/compare/0b093b7c...986...
Creating good, representative, benchmarks is hard. I use speedtest1 [1] but this tests WASM/SQLite and my VFSes, not the Go driver that wraps it.
I already forked this benchmark and will run it regularly, but I may also adapt it into a Go benchmark (easier to run, benchstat compatible, etc).
[1] https://sqlite.org/src/file/test/speedtest1.c
https://zig.news/kristoff/building-sqlite-with-cgo-for-every...
There's also debuggability and other aspects of development.
It's an anecdote, but I've had a better experience debugging issues while developing SQLite extensions and VFSes for this driver than I've had in other "cross runtime" / "bindings" work I've done before (with Cgo, JNI, P/Invoke).
Edit: have you tried that zig workflow for cross-compiling?
Edit2: and I'm also aware that zig also uses wasm binary and its own wasm2c, written from scratch, for its own bootstrapping:
https://ziglang.org/news/goodbye-cpp/
I mean developing a complex wrapper/binding that involves Go calls C, which then calls back to Go, which again calls C, and putting breakpoints on the Go bits multiple layers deep, and being able to inspect the full stack of those Go bits.
Last I've tried with Cgo, stuff has a high probability of hanging your entire process (e.g. if the C library acquired some lock or something).
There's also the permanent fear that (bugs, API misuse…) leads to the C library corrupting your Go memory, which is much harder to do if the C bits run in a sandbox. SQLite is "imune" to bugs, but not API misuse.
Tbh, I haven't had a better experience with JNI, JNA, PInvoke…
And yes, I've looked at zig, even used it initially to build the WASM blob. I actually think it'd be a worthy endeavour to build a better Cgo driver. But having Cgo and WASM versions of this in the same repo is just too much effort at this point (I've considered it).
Once that's done correctly and at a high quality, your life is easier.
Developing those bindings is another story. But PInvoke is one of the best development stories, I'm not dinging on it.
I really think that C# with P/Invoke and the Visual Studio debugger is a cut above the rest.
The parent directory includes code that uses it. I'm most proud of this SQLite virtual table module that proxies queries to remote ADO.NET connections, allowing you to write joins directly between local SQLite tables and remote SQL Server tables. https://github.com/electroly/sqlnotebook/blob/master/src/Sql...
This is exposed to the user as a "LINK" option on the "IMPORT DATABASE" statement: https://sqlnotebook.com/import-database-stmt.html
I've also got a generic virtual table module that lets me easily write table-valued functions in C#: https://github.com/electroly/sqlnotebook/blob/master/src/Sql...
I don't have too many table-valued functions yet but one example is the "LIST_XLS_WORKSHEETS" function: https://sqlnotebook.com/list-xls-worksheets-func.html
I have my own implementation of SQLite's grammar so I can embed it inside my larger grammar which adds things like DECLARE, IF, and WHILE: https://github.com/electroly/sqlnotebook/blob/master/src/Sql...
The goal is to provide various "supercharged" features to base SQLite by taking advantage of all the extension points I can. I wish some went further; in particular the virtual table API doesn't "push down" enough of the original query to allow the module to avoid doing N+1 queries in some cases.
Always happy to see people using SQLite in unique ways!
Will have a look at your code!
Somehow it became common to do it in more complex ways, and the return to AOT compilation is rediscovering it.
The way VS debugging allows for mixed language debugging is only comparable with Netbeans/Eclipse, but neither JNI nor Panama are as good as P/Invoke.
*in garbage-collected languages, otherwise it's pretty much like calling C from Rust.
Also had “fun” building some Xamarin Android PInvoke-to-JNI bindings. I just died a little bit remembering this.
As for the first argument - this really does not represent the average experience in the current day ecosystem. Kinect 2 was a thing 9 years ago and together with Unity it's pretty much an exotic scenario. Unity in general is really not a good example until they get Unity 6 out and move over to vanilla .NET.
zig is not only the language, it's also a build system that compiles C (also in Go CGO use scenarios) better than the default C compilers, allowing order of magnitude easier cross compilation. It's described in the link I've already provided exactly by compiling SqLite for its use in Go!
—-
1: https://github.com/tursodatabase/libsql/tree/main/libsql-ser...
2: https://news.ycombinator.com/item?id=38602175
Not using a standard interface (i.e. database/sql) sounds dodgy, but maybe there's really no problem.
Apologies if this is a stupid question.
If you were using an ORM, usually the ORM would expect to be sitting on top of the lower-level database/sql. (It's turtles all the way down!)
Dropping the database/sql compatibility requirement allows for more direct (and possibly faster) interaction with the database. This approach can unlock database-specific features and potentially improve performance, but it clearly deviates from the standard API envisioned in database/sql. You might also end up losing the ability to use an ORM, but usually you don't really want to mix ORM and non-ORM in the same codebase anyway.
However, adhering to database/sql also brings significant benefits, such as simplifing switching between different database systems with only small code alterations. While schema changes might still be necessary, they are generally external to the core codebase.
I've already decided Yes to SQLite (only) and No to ORMs, so maybe zombiezen is the way to go.
Pretty much the only thing the lower level wrapper does for you is simplify some of the memory management inherent to a C API (who allocates? who frees? for everything even something as trivial as an error message: GC+copying handles lightweight types like strings and errors, io.Closer is used for everything else).
At the same time, I also made a serious effort to make many SQLite features (like blob IO, nested transactions, JSON, time handling) easy to access for users of the database/sql driver.
A lot of the interesting features of SQLite are connection oriented, and compared to other databases, connections are cheap (because it's all local). So the things database/sql offers are (relatively) less valuable, and it makes other things harder.
Yet (IMO) connection pools still offer some performance benefits, not having to worry about (re)preparing statements on new connections is still helpful, and many of the connection oriented things you might want to do can be exposed through database/sql with some imagination.
I have a lot of respect for crawshaw and zombiezen; I started from their PoV. But I've since come around to database/sql.
A lot of what is mentioned in [1] has been fixed in database/sql or can be fixed in drivers: parameter names now work in database/sql; their approach to statement caching isn't necessarily better, but can be added to a driver, or live in a compatible framework; panics vs. errors is contentious; savepoints can be made to work just fine [2]; and for contexts, I started with crawshaw and built an improved version of it into my driver, that is both low overhead, interacts well with the Go scheduler, and doesn't start additional goroutines.
Living in database/sql land gives you access to other amazing tooling like SQLC, just to name a single one. And it doesn't need to be either/or: drivers should support both high and low level APIs.
[1]: https://crawshaw.io/blog/go-and-sqlite
[2]: https://pkg.go.dev/github.com/ncruces/go-sqlite3/driver#exam...
This is github specific, not part of commonmark AFAIK
But you can try to use my build scripts, and tweak my code, to build SQLite+sqlite-vss+Faiss into one large WASM. If you use my patches/imports/exports/config for SQLite, my binding will gladly take your WASM blob, without modification to the Go code.
All you need to do is set this global variable (and not import package embed, which embeds my WASM and sets this variable for you at init): https://pkg.go.dev/github.com/ncruces/go-sqlite3#pkg-variabl...
Ping me on GitHub if you need help.
Vector search is on my meters long todo list, but I'd rather do it in Go if at all possible.
> But you can try to use my build scripts, and tweak my code, to build SQLite+sqlite-vss+Faiss into one large WASM
That makes perfect sense to me! I'm looking into your build scripts right now. I'm pretty proficient in Go, but haven't touched wasm or C much, so. I may reach out with more questions.
My plan was to evaluate Go for cosine similarity indexing, and if I found something promising, port the virtual table.