SQLite in Go, with and Without Cgo
datastation.multiprocess.io
datastation.multiprocess.io
There's often (for instance, in Go projects wanting to avoid cgo) a desire for everything to be in the single source language - Go. In what resembles NIH syndrome, there will be clones of existing libraries, offering little over the original except being "Written in Go". From experience this often makes for more bugs, as the Go version is commonly much younger and lessor used than the existing non-Go library.
The Python world does it a lot less, perhaps the slowness of Python helps encourage using non-python libraries in Python modules. But that sure does making building and distributing Python projects "fun".
What I'm trying to say is that:
A world where every language community has it's own SQLite project because the communities shun code written in other languages just feels like a profound waste.
Rust can use the C calling convention to call C functions or export functions to C code, but this requires extra annotations. By default, Rust uses its own unstable ABI.
[1]: https://dev.to/kristoff/zig-makes-go-cross-compilation-just-...
Cross compiling C sucks.
You can target a newer glibc like this: -target x86_64-linux-gnu.2.28
You can target musl libc like this: -target x86_64-linux-musl
However the security aspect is much more relevant.
You lose probably a week of work every year to stuff like this in Python
For SQLite it seems it would be ok, as there's no network traffic, but I've had issues where network glitches (Kafka publisher C library) would cause unrecoverable CPU spikes and an increase in OS threads that never recovered.
So that's the functional reason behind the Go communities desire to write everything in Go. Plus a lot of the people who love Go also tend to be the sort who would enjoy re-writing C libraries into a nice new language.
It still can be an interesting project and if it proves to be correct and eventually shows some advantages vs. using the C version, it might become a nice alternative.
Node.js can take advantage of WASM which is pretty handy in some cases.
And that is good.
In special, this is driven by how TERRIBLE all the dance around C is. Sqlite is among the easiest, yet, it also cause trouble: suddenly, you need to bring certain LLVM, Visual Studio Tools, etc. And then you HOPE all the other tools use the correct env_vars, settings, etc.
And then, you hit a snag, and waste time dancing around C.
https://news.ycombinator.com/item?id=16741043
A big part of my pain, and the pain I've observed in 15 years of industry, is programming language silos. Too much time is spent on "How do I do X in language Y?" rather than just "How do I do X?"
For example, people want a web socket server, or a syntax highlighting library, in pure Python, or Go, or JavaScript, etc. It's repetitive and drastically increases the amount of code that has to be maintained, and reduces the overall quality of each solution (e.g. think e-mail parsers, video codecs, spam filters, information retrieval libraries, etc.).
There's this tendency of languages to want to be the be-all end-all, i.e. to pretend that they are at the center of the universe. Instead, they should focus on interoperating with other languages (as in the Unix philosophy).
One reason I left Google over 6 years ago was the constant code churn without user visible progress. Somebody wrote a Google+ rant about how Python services should be rewritten in Go so that IDEs would work better. I posted something like <troll> ... Meanwhile other companies are shipping features that users care about </troll>. Google+ itself is probably another example of that inward looking, out of touch view. (which was of course not universal at Google, but definitely there)
This is one reason I'm working on https://www.oilshell.org -- with a focus on INTEROPERABILITY and stable "narrow waists" (as discussed on the blog https://www.oilshell.org/blog/2022/02/diagrams.html )
Thus language-native versions of sqllite really can be viewed as language-specific file format readers/writers like any other JSON/YAML library.
Most other languages want native libraries not because of some bizarre fear of C, but because of the semantics. Native libraries work with all the features of the language, whatever they may be. A naive native binding to SQLite in Rust may be functional, but it will not, for instance, support Rust's iterators. That's kind of a bummer. Any real Rust library for something as big as SQLite will of course provide them, but as you go further down the list of "popular libraries" the bindings will get more and more foreign.
Also, the design of these dynamic scripting languages were non-trivially bent around treating the ability to bind as C as a first-class concern. I think if they were never designed around that, there are many things that would not look the same. One big one is that Python would be multithreaded cleanly today if it didn't consider that a big deal because the primary problem with the GIL isn't Python itself, but the C bindings you'd leave behind if you remove it. Go's issue is mostly that it came far enough into C's extremely slow, but steady, decline that it was able to make it a second-class concern instead of a first, and not force the entire language's design and runtime to bend around making C happy.
As it happens, in my other window, I'm writing Go code using GraphViz bindings, and I'm experiencing exactly this problem. It works, yes. But it's very non-idiomatic. I've had to penetrate the abstraction a couple of time to pass parameters down to GraphViz that the wrapper didn't directly support. (Fortunately, it also provided the capability to do so, but that doesn't always happen.) There's a function I have to call to indicate that a particular section is using the HTML-like label support GraphViz has, which in Go, takes a string and appears to return the exact same string, but the second string is magical and if used as the label will be HTML-like.
This is not special to Go, I've encountered this problem in Python (the Tkinter bindings are a ton of "fun"; the foreign language in this case is Tcl, and if you want to get fancy you'll end up learning some Tcl too!), Perl, several other places. A native library would be much nicer.
Finally, the Go SQLite project isn't it's own SQLite. It's actually a C-to-Go compile, as I understand it. That's not really a separate project.
The original argument was that reimplementing everything in Go is better and avoids a bunch of trade-offs, mainly: more complicated and slower builds, loss of cross-compilation, tooling. But it completely ignores the real cost of maintaining software, even if 'just a translation'. Having the same underlying libraries interoperating with Go, Node, Java, etc is a massive advantage, gives you predictable performance, memory usage, and known reliability regardless of the host language. Who would ever port and maintain a beast such as a libvips or IM port and all the 25 other image libraries they depend on?
What really should be addressed is precisely that pain, so users of modules using CGO don't have to worry about it. Fix it, not abandon it.
Of course, there are still valid reasons for using cgo. But if you are building a library, you should ask yourself the "cgo or not cgo" question even more seriously, because you will be forcing everybody who uses your library to use cgo too...
None of this means that you can't make an absolute mess of concurrency, but that's not a memory safety concern.
I never heard of news that the situation had changed, but if it has I'm most interested in when it did! :)
func main() {
var x, y int64
doneCh := make(chan struct{})
inc := func() {
for i := 0; i < 2<<20; i++ {
x++ // line 13 in test/main.go
atomic.AddInt64(&y, 1)
}
doneCh <- struct{}{}
return
}
go inc()
go inc()
<-doneCh
<-doneCh
fmt.Printf("x, y = %v, %v\n", x, y)
}
This prints something like: $ go run main.go
x, y = 3482626, 4194304
If you run it with the race detector the problem is clear: $ go run -race main.go
==================
WARNING: DATA RACE
Read at 0x00c0001bc008 by goroutine 7:
main.main.func1()
/home/jrockway/test/main.go:13 +0x50
Previous write at 0x00c0001bc008 by goroutine 8:
main.main.func1()
/home/jrockway/test/main.go:13 +0x64
Goroutine 7 (running) created at:
main.main()
/home/jrockway/test/main.go:19 +0x176
Goroutine 8 (running) created at:
main.main()
/home/jrockway/test/main.go:20 +0x184
==================
x, y = 4189962, 4194304
Found 1 data race(s)
exit status 66
This is not strictly a memory safety problem (this can't crash the runtime), but a program that returns the wrong answer is pretty useless, so there is that. Obviously if x were going to be used as a pointer through the `unsafe` package, you could have problems. (Though I think that x will always be less than y, so if you have proved that memory[base+y] is safe to read, then memory[base+x] is safe to read. But it's easy to imagine a scenario where you overcount instead of undercount.It's really cool because cross-compilation is, imo, the most painful part of CGO - right after being incompatible with the whole runtime model, being blocking, and being hard to profile with Go profiling tools.
[0]: https://dev.to/kristoff/zig-makes-go-cross-compilation-just-...
Disclaimer: Not affiliated with the author in any way.
Getting the right libs is really the hardest part of cross compiling, and zig can't really help there. Not to crap on zig, it's pretty badass, just offering a well battle tested alternative.
I'm now using https://github.com/crawshaw/sqlite and it seems to address those issues (but I haven't gotten around to setting up a proper test to confirm). It may be worth perusing if you do run into performance problems. It does come with the caveat of not being a database/sql driver though.
The main interesting thing to me is when the “topic de jour” has very little to do with what I’d normally associate with HN (language genealogy, Neanderthals, and ancient currencies are some examples from recent memory).
Which brings us to the obvious question, what improvements can be made? What if the Go code was handcrafted instead of automatically generated?
https://gitlab.com/cznic/sqlite/-/issues/39 is one issue from a year ago where they talk about insert performance and some optimizations as well, so I believe the author is aware of it.
Anyway, I hope a native Go version does pick up (for everything currently depending on cgo for that matter), it makes cross-compilation a lot easier.
That's not what TFA finds.
What it finds is that INSERT may be about half as fast, but SELECT with a GROUP BY is ridiculously slow with modernc as your row count grows:
# rows mattn avg (s) modernc avg (s)
10000 0.000048 0.003762
479827 0.000048 0.230283
4798270 0.000051 2.791617
I wish more usage patterns were tested.How could one be doing constant work and the other O(rows) work if it's the same code (just compiled from one language to another)?
Turns out the benchmarking code is wrong: it didn't read the rows returned from db.Query, so the mattn version simply didn't wait for the results to arrive. Once you apply this patch:
diff --git a/cgo/main.go b/cgo/main.go
index 8796b3d..9a74a2f 100644
--- a/cgo/main.go
+++ b/cgo/main.go
@@ -82,11 +82,15 @@ CREATE TABLE people (
panic(err)
}
}
- fmt.Printf("%f,%d,insert,cgo\n", float64(time.Now().Sub(t1)) / 1e9, rows)
+ fmt.Printf("%f,%d,insert,cgo\n", float64(time.Now().Sub(t1))/1e9, rows)
t1 = time.Now()
- _, err = db.Query("SELECT COUNT(1), age FROM people GROUP BY age ORDER BY COUNT(1) DESC")
- fmt.Printf("%f,%d,group_by,cgo\n", float64(time.Now().Sub(t1)) / 1e9, rows)
+ res, _ := db.Query("SELECT COUNT(1), age FROM people GROUP BY age ORDER BY COUNT(1) DESC")
+ for res.Next() {
+ var count, age int
+ _ = res.Scan(&count, &age)
+ }
+ fmt.Printf("%f,%d,group_by,cgo\n", float64(time.Now().Sub(t1))/1e9, rows)
}
}
}
modernc SELECT performance becomes pretty comparable, actually a little bit faster than mattn on my Intel Mac with high row count.Not only that, modernc INSERT is noticeably faster on my Intel Mac...
I'll post an update and credit you.
I do suppose that the group_by performance can be improved by working on the c->go compiler, though. I think that's a more worthwhile effort than rewriting sqlite.
But if performance is worse than the C wrapper, there is surely no point whatsoever for such a native version to exist? Isn't the whole point of a native Go/whatever version to avoid the interop penalty?
> The C version is very well tested
Couldn't the same test suite be used for a Go/whatever rewrite, or at least be ported to Go too?
If you want to avoid cgo. There are two reasons for that: easy cross-compilation, and https://dave.cheney.net/2016/01/18/cgo-is-not-go
> Couldn't the same test suite be used for a Go/whatever rewrite, or at least be ported to Go too?
And then port each and every update as well? The only use of such a rewrite is performance improvement in go programs. Surely improving the c-to-go compiler is a better investment of effort? It would benefit other projects too. Rewriting sqlite in go will only lead to less effort on the c-to-go compiler.
CGO require a c compiler (not always easy available) and make cross-compilation harder
I still would not rewrite SQLite. It's just too good how it is.
But I also really don't like using cgo, for all the reasons stated elsewhere. So these days I generally avoid SQLite in my Go programs, which is unfortunate.
What I think I'd like best of all is if there was an out-of-process SQLite network daemon mode that could run over Unix sockets. So one could run `sqlite3 daemon unix:///run/sqlite.sock` and then communicate with it like a traditional SQL database, but with most of the simplicity and power of SQLite intact.
I know there are at least some attempts at creating network daemons for SQLite, but my impression was that none of them seemed up to the (very high) bar set by SQLite itself. Also, there's no SQLite network driver for Go. Please someone correct me if I'm wrong about these though.
https://github.com/cvilsmeier/sqinn https://github.com/cvilsmeier/sqinn-go
- Load up Debian
- install cross-build-essential
- dpkg --add-architecture armhf
- install the lib you want to link
- set "CC" to the armhf compiler
- go build
I don't see how but I'm curious to what others think.
Something that has caused me a lot of trouble in the past and is now on my "never do" list is using SQLite for tests against an app that runs Postgres in production. It's pretty similar, but passing tests easily equal failing production. (The last straw for me was SQLite considering "f" true, while Postgres considers it false.) Your suggestion is a little different than that, but the same principle applies. Test your app against the database you're going to use. If you must support two database engines, then you need a test matrix that runs tests against both.
I wrote my comment with the experience working with deploying open source software personally recently. They already had support for multiple database backends, with SQLite being one of them. They already had test suites, and while adding support for another SQLite implementation isn't free, it did only take a few minutes.
modernc.org/ql
try several thousand times, twice as slow was the best case measured. The group by performance is catastrophic.
The real insight is "it depends".
Zero.
I’m not some deeply knowledgeable go-internals core team committer. I’m a pretty mediocre go dev who spends most days fighting silly fires in the 6-7 other languages any project ends up having to deal with.
Zero problems with cross compiling the cgo version of SQLite for 3 years now.
(I usually make a Store interface which is application specific and doesn't even assume there is an SQL database underneath. Then I make "driver" packages for each storage system - be it PostgreSQL, SQLite, flat files, timeseries etc. I have only one set of unit tests that is then run against all drivers. And when I have a caching layer, I also run all the unit tests with or without caching. The cache is usually just an adapter that wraps a Store type. I maintain separate schemas and drivers for each "driver" because I have found that this is actually faster and easier than trying to make generic SQL drivers for instance.)
However, I always keep the SQLite support and it is usually the default when you start up the application without explicitly specifying a database. This means that it is easy for other developers to do ad-hoc experiments or even create integration tests without having to fire up a database, which even when you are able to do it quickly, still takes time and effort. In production you usually want to point to a PostgreSQL (or other) database. Usually, but not always.
I also use it extensively in unit tests (often creating and destroying in-memory databases hundreds of times during just a couple of seconds of tests). I run all my tests on every build while developing and then speed matters a lot. When testing with PostgreSQL I usually set a build tag that specifies that I want to run the tests against PostgreSQL as well. I always want to run all the database tests - I don't always need to run them against PostgreSQL
(Actually, I made a quick hack called Drydock which takes care of creating a PostgreSQL instance and creates one database per test. This is experimental, but I've gotten a lot of use out of it: https://github.com/borud/drydock)
The reason I do this is that it results in much quicker turnaround during the initial phase when the data model may go through several complete rewrites. The lack of friction is significant.
SQLite has actually surprised me. I use it in a project where I routinely have tens of millions of rows in the biggest table. And it still performs well enough at well north of 100M rows. I wouldn't recommend it in production, but for a surprising number of systems you could if you wanted to.
The transpiled SQLite is very interesting to me for two reasons. It makes cross compiling a lot less complex. I make extensive use of Go and SQLite on embedded ARM platforms and then you either have to choose between compiling on the target platform or mess around with C libraries. It also eliminates the need to do two stage Docker builds (which cuts down building Docker images from 50+ seconds to perhaps 4-5 seconds).
The transpiled version is slower by quite a lot. I haven't done a systematic benchmark, but I noticed that a server that stores 30-40 datapoints per second went from 0.5% average CPU load to about 2% average CPU load. I'm not terribly worried about it, but it does mean that when I increase the influx of data I'm most likely going to hit a wall sooner.
I'll be using the transpiled SQLite a lot more in the coming year and I'll be on the Gophers Slack so if anyone is interested in sharing experiences, discussing SQLite in Go, please don't be shy.