Skip to content

Commit

Permalink
optimize parsing common timestamps
Browse files Browse the repository at this point in the history
This commit adds a dedicated function for parsing the two most common
timestamps used by sqlite3.

```
goos: darwin
goarch: arm64
pkg: github.com/charlievieth/go-sqlite3
cpu: Apple M4 Pro
                            │ base.10.txt  │             new.10.txt              │
                            │    sec/op    │   sec/op     vs base                │
Suite/BenchmarkParseTime-14   11.347µ ± 0%   8.044µ ± 0%  -29.11% (p=0.000 n=10)

                            │ base.10.txt  │            new.10.txt            │
                            │     B/op     │     B/op      vs base            │
Suite/BenchmarkParseTime-14   1.713Ki ± 0%   1.713Ki ± 0%  ~ (p=1.000 n=10) ¹
¹ all samples are equal

                            │ base.10.txt │           new.10.txt           │
                            │  allocs/op  │ allocs/op   vs base            │
Suite/BenchmarkParseTime-14    64.00 ± 0%   64.00 ± 0%  ~ (p=1.000 n=10) ¹
¹ all samples are equal
```

timefmt benchmarks:
```
goos: darwin
goarch: arm64
pkg: github.com/charlievieth/go-sqlite3/internal/timefmt
cpu: Apple M4 Pro
BenchmarkParse/Stdlib-14         	11050214	       103.5 ns/op	       0 B/op	       0 allocs/op
BenchmarkParse/Timefmt-14        	34134667	        34.45 ns/op	       0 B/op	       0 allocs/op
BenchmarkParse/Timefmt_T-14      	33790362	        33.97 ns/op	       0 B/op	       0 allocs/op
PASS
ok  	github.com/charlievieth/go-sqlite3/internal/timefmt	3.814s
```
  • Loading branch information
charlievieth committed Feb 7, 2025
1 parent 15edb4d commit 1c28d1f
Show file tree
Hide file tree
Showing 4 changed files with 280 additions and 8 deletions.
169 changes: 168 additions & 1 deletion internal/timefmt/timefmt.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package timefmt

import "time"
import (
"errors"
"strconv"
"time"
)

const digits = "0123456789"

Expand Down Expand Up @@ -120,3 +124,166 @@ func Format(t time.Time) []byte {

return b
}

func isLeap(year int) bool {
return year%4 == 0 && (year%100 != 0 || year%400 == 0)
}

// daysBefore[m] counts the number of days in a non-leap year
// before month m begins. There is an entry for m=12, counting
// the number of days before January of next year (365).
var daysBefore = [...]int32{
0,
31,
31 + 28,
31 + 28 + 31,
31 + 28 + 31 + 30,
31 + 28 + 31 + 30 + 31,
31 + 28 + 31 + 30 + 31 + 30,
31 + 28 + 31 + 30 + 31 + 30 + 31,
31 + 28 + 31 + 30 + 31 + 30 + 31 + 31,
31 + 28 + 31 + 30 + 31 + 30 + 31 + 31 + 30,
31 + 28 + 31 + 30 + 31 + 30 + 31 + 31 + 30 + 31,
31 + 28 + 31 + 30 + 31 + 30 + 31 + 31 + 30 + 31 + 30,
31 + 28 + 31 + 30 + 31 + 30 + 31 + 31 + 30 + 31 + 30 + 31,
}

func daysIn(m time.Month, year int) int {
if m == time.February && isLeap(year) {
return 29
}
return int(daysBefore[m] - daysBefore[m-1])
}

// isDigit reports whether s[i] is in range and is a decimal digit.
func isDigit(s string, i int) bool {
if len(s) <= i {
return false
}
c := s[i]
return '0' <= c && c <= '9'
}

func commaOrPeriod(b byte) bool {
return b == '.' || b == ','
}

var errBad = errors.New("bad value for field") // placeholder not passed to user

func parseNanoseconds(value string, nbytes int) (ns int, rangeErrString string, err error) {
if !commaOrPeriod(value[0]) {
err = errBad
return
}
if nbytes > 10 {
value = value[:10]
nbytes = 10
}
if ns, err = strconv.Atoi(value[1:nbytes]); err != nil {
return
}
if ns < 0 {
rangeErrString = "fractional second"
return
}
// We need nanoseconds, which means scaling by the number
// of missing digits in the format, maximum length 10.
scaleDigits := 10 - nbytes
for i := 0; i < scaleDigits; i++ {
ns *= 10
}
return
}

func parse(s string, local *time.Location) (time.Time, bool) {
// parseUint parses s as an unsigned decimal integer and
// verifies that it is within some range.
// If it is invalid or out-of-range,
// it sets ok to false and returns the min value.
ok := true
parseUint := func(s string, min, max int) (x int) {
for _, c := range []byte(s) {
if c < '0' || '9' < c {
ok = false
return min
}
x = x*10 + int(c) - '0'
}
if x < min || max < x {
ok = false
return min
}
return x
}

// Parse the date and time.
// "2006-01-02 15:04:05.999999999-07:00"
if len(s) < len("2006-01-02 15:04:05") {
return time.Time{}, false
}
year := parseUint(s[0:4], 0, 9999) // e.g., 2006
month := parseUint(s[5:7], 1, 12) // e.g., 01
day := parseUint(s[8:10], 1, daysIn(time.Month(month), year)) // e.g., 02
hour := parseUint(s[11:13], 0, 23) // e.g., 15
min := parseUint(s[14:16], 0, 59) // e.g., 04
sec := parseUint(s[17:19], 0, 59) // e.g., 05

if !ok || !(s[4] == '-' && s[7] == '-' && (s[10] == ' ' || s[10] == 'T') && s[13] == ':' && s[16] == ':') {
return time.Time{}, false
}
s = s[19:]

// Parse the fractional second.
var nsec int
if len(s) >= 2 && s[0] == '.' && isDigit(s, 1) {
n := 2
for ; n < len(s) && isDigit(s, n); n++ {
}
nsec, _, _ = parseNanoseconds(s, n)
s = s[n:]
}

// Parse the time zone.
t := time.Date(year, time.Month(month), day, hour, min, sec, nsec, time.UTC)
if len(s) != 1 || s[0] != 'Z' {
if len(s) != len("-07:00") {
return time.Time{}, false
}
hr := parseUint(s[1:3], 0, 23) // e.g., 07
mm := parseUint(s[4:6], 0, 59) // e.g., 00
if !ok || !((s[0] == '-' || s[0] == '+') && s[3] == ':') {
return time.Time{}, false
}
zoneOffset := (hr*60 + mm) * 60
if s[0] == '-' {
zoneOffset *= -1
}
t = t.Add(-(time.Duration(zoneOffset) * time.Second))

// Use local zone with the given offset if possible.
t2 := t.In(local)
_, offset := t2.Zone()
if offset == zoneOffset {
t = t2
} else {
t = t.In(time.FixedZone("", zoneOffset))
}
}

return t, true
}

// Parse is an specialized version of time.Parse that is optimized for
// the below two timestamps:
//
// - "2006-01-02 15:04:05.999999999-07:00"
// - "2006-01-02T15:04:05.999999999-07:00"
func Parse(s string, local *time.Location) (time.Time, error) {
if t, ok := parse(s, local); ok {
return t, nil
}
if len(s) > 10 && s[10] == 'T' {
return time.Parse("2006-01-02T15:04:05.999999999-07:00", s)
}
return time.Parse("2006-01-02 15:04:05.999999999-07:00", s)
}
90 changes: 90 additions & 0 deletions internal/timefmt/timefmt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,61 @@ func TestFormatTimeAllocs(t *testing.T) {
}
}

func TestParse(t *testing.T) {
rr := rand.New(rand.NewSource(time.Now().UnixNano()))
locs := make([]*time.Location, 1000)
for i := range locs {
offset := rr.Intn(60 * 60 * 14) // 14 hours
if rr.Int()&1 != 0 {
offset = -offset
}
locs[i] = time.FixedZone(strconv.Itoa(offset), offset)
}
// Append some standard locations
locs = append(locs, time.Local, time.UTC)

times := []time.Time{
{},
time.Now(),
time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC),
time.Date(1, 1, 1, 1, 1, 1, 1, time.UTC),
time.Date(20_000, 1, 1, 1, 1, 1, 1, time.UTC),
time.Date(-1, 0, 0, 0, 0, 0, 0, time.UTC),
time.Date(2028, 2, 29, 0, 0, 0, 0, time.UTC), // Leap day
time.Date(2028, 2, 29, 1, 1, 1, 1, time.Local), // Leap day
}
for i := 0; i < 100; i++ {
times = append(times, time.Now().Add(time.Duration(rr.Int63n(int64(time.Hour*24*365)))))
}

passed := 0
for _, loc := range locs {
for _, tt := range times {
tt = tt.In(loc)
for _, format := range sqlite3.SQLiteTimestampFormats[:2] {
s := tt.Format(format)
want, err := time.ParseInLocation(format, s, loc)
if err != nil {
continue
}
got, err := timefmt.Parse(s, loc)
if err != nil {
t.Error(err)
continue
}
if !got.Equal(want) {
t.Errorf("timefmt.Parse(%q) = %s; want: %s", s, got, want)
continue
}
passed++
}
}
}
if passed == 0 {
t.Fatal("No tests passed")
}
}

func BenchmarkFormat(b *testing.B) {
loc, err := time.LoadLocation("America/New_York")
if err != nil {
Expand All @@ -64,3 +119,38 @@ func BenchmarkFormat(b *testing.B) {
_ = timefmt.Format(ts)
}
}

func BenchmarkParse(b *testing.B) {
layout := sqlite3.SQLiteTimestampFormats[0]
loc, err := time.LoadLocation("America/New_York")
if err != nil {
b.Fatal(err)
}
ts := time.Date(2024, 1, 2, 15, 4, 5, 123456789, loc).Format(layout)

b.Run("Stdlib", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_, err := time.Parse(layout, ts)
if err != nil {
b.Fatal(err)
}
}
})
b.Run("Timefmt", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_, err := timefmt.Parse(ts, time.Local)
if err != nil {
b.Fatal(err)
}
}
})
b.Run("Timefmt_T", func(b *testing.B) {
ts := time.Date(2024, 1, 2, 15, 4, 5, 123456789, loc).Format(sqlite3.SQLiteTimestampFormats[1])
for i := 0; i < b.N; i++ {
_, err := timefmt.Parse(ts, time.Local)
if err != nil {
b.Fatal(err)
}
}
})
}
8 changes: 5 additions & 3 deletions sqlite3.go
Original file line number Diff line number Diff line change
Expand Up @@ -2756,9 +2756,11 @@ func (rc *SQLiteRows) nextSyncLocked(dest []driver.Value) error {
s = strings.TrimSuffix(s, "Z")
var err error
var t time.Time
for _, format := range SQLiteTimestampFormats {
if t, err = time.ParseInLocation(format, s, time.UTC); err == nil {
break
if t, err = timefmt.Parse(s, time.UTC); err != nil {
for _, format := range SQLiteTimestampFormats[2:] {
if t, err = time.ParseInLocation(format, s, time.UTC); err == nil {
break
}
}
}
if err != nil {
Expand Down
21 changes: 17 additions & 4 deletions sqlite3_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3468,7 +3468,17 @@ func benchmarkParseTime(b *testing.B) {
b.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(`CREATE TABLE time_bench (ts DATETIME NOT NULL);`); err != nil {
const createTableStmt = `
CREATE TABLE time_bench (
ts1 DATETIME NOT NULL,
ts2 DATETIME NOT NULL,
ts3 DATETIME NOT NULL,
ts4 DATETIME NOT NULL,
ts5 DATETIME NOT NULL,
ts6 DATETIME NOT NULL
);`
// if _, err := db.Exec(`CREATE TABLE time_bench (ts DATETIME NOT NULL);`); err != nil {
if _, err := db.Exec(createTableStmt); err != nil {
b.Fatal(err)
}
// t := time.Date(year, month, day, hour, min, sec, nsec, loc)
Expand All @@ -3478,12 +3488,15 @@ func benchmarkParseTime(b *testing.B) {
}
ts := time.Date(2024, 1, 2, 15, 4, 5, 123456789, loc)
for i := 0; i < 8; i++ {
_, err := db.Exec(`INSERT INTO time_bench VALUES(?)`, ts)
_, err := db.Exec(`INSERT INTO time_bench VALUES(?, ?, ?, ?, ?, ?)`,
ts, ts, ts, ts, ts, ts)
if err != nil {
b.Fatal(err)
}
}
stmt, err := db.Prepare(`SELECT ts FROM time_bench LIMIT 1;`)

// stmt, err := db.Prepare(`SELECT ts1, ts2, ts3, ts4, ts5, ts6 FROM time_bench LIMIT 1;`)
stmt, err := db.Prepare(`SELECT ts1, ts2, ts3, ts4, ts5, ts6 FROM time_bench;`)
if err != nil {
b.Fatal(err)
}
Expand All @@ -3495,7 +3508,7 @@ func benchmarkParseTime(b *testing.B) {
}
var t time.Time
for rows.Next() {
if err := rows.Scan(&t); err != nil {
if err := rows.Scan(&t, &t, &t, &t, &t, &t); err != nil {
b.Fatal(err)
}
}
Expand Down

0 comments on commit 1c28d1f

Please sign in to comment.