Sijin T V
Sijin T V A passionate Software Engineer who contributes to the wonders happenning on the internet

Zero-Allocation Parsing and Serialization in High-Throughput Go Services

Our ingest path parses text records off a network buffer and indexes them. At 1.2M events/s, the parser was the hottest function in the service – not because parsing is expensive, but because every record spawned a hidden family of heap allocations: one []byte to string conversion per field, each of which escaped to the heap because the string outlived the parse.

The allocation count was the real story. go test -benchmem reported a “simple” parse loop at ~10 allocs/op, and 1.2M events/s times ten allocs is ~12M allocations/s. The GC was running every ~300ms under load, and the p99 latency graph had the shape of a forest: a wall of pause spikes.

Zero-allocation parsing is not about cleverness. It is about not paying for conversions you do not need. This post is the pattern we ended up with, and the measurements that justify it.

The hidden cost of the obvious code

Three “obvious” choices in a typical Go parser are each an allocation factory:

  • string(b) on a []byte that escapes allocates a string header and a copy. If the string outlives the slice, the copy is mandatory; the compiler cannot keep it on the stack.
  • strings.Split allocates a string per field. bytes.Split allocates a slice header per field but shares the input buffer – the sub-slices reference the original, no copy. Operating on []byte end to end is the first big win.
  • strconv.ParseInt and friends take a string, forcing a conversion even when you hold a []byte.

The pattern: never convert []byte to string until you are ready to hand the data off. Parse, index, and compare directly on the byte slice.

The field-scanner pattern

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
type Row struct {
	UserID int64
	Bytes  int64
	Path   string
}

// parseRow fills dst from a line like "1234,5678,/v1/orders".
// No allocations on the happy path; Path is the only string, assigned by the caller.
func parseRow(line []byte, dst *Row) error {
	i := 0
	for field := 0; ; field++ {
		j := bytes.IndexByte(line[i:], ',')
		if j < 0 {
			if field != 2 {
				return errShortLine
			}
			j = len(line) - i
		}
		part := line[i : i+j]
		switch field {
		case 0:
			id, err := parseInt(part)
			if err != nil {
				return err
			}
			dst.UserID = id
		case 1:
			n, err := parseInt(part)
			if err != nil {
				return err
			}
			dst.Bytes = n
		case 2:
			dst.Path = string(part)
		}
		i += j + 1
		if field == 2 {
			return nil
		}
	}
}

// parseInt parses a base-10 int directly from bytes; strconv needs a string.
func parseInt(b []byte) (int64, error) {
	if len(b) == 0 {
		return 0, errEmpty
	}
	neg := false
	if b[0] == '-' {
		neg, b = true, b[1:]
	}
	var v int64
	for _, c := range b {
		if c < '0' || c > '9' {
			return 0, errInvalid
		}
		v = v*10 + int64(c-'0')
	}
	if neg {
		v = -v
	}
	return v, nil
}

The two structural choices that matter: bytes.IndexByte on a sub-slice (no allocation, one cache-friendly scan) and parseInt over raw bytes (no strconv string conversion). dst.Path is the only string conversion in the whole path, and it is the one place a heap copy is genuinely required because the caller owns the field.

Serialization, same idea

The output side mirrors the input. Build responses into a scratch []byte with strconv.AppendInt and append, not fmt.Sprintf, and you eliminate the per-field temporary strings the formatter’s varargs allocates. A pooled scratch buffer (see the sync.Pool notes in our other post) makes the whole request cycle allocation-free except for the final copy into the response.

For JSON, encoding/json allocates on decode even for fixed schemas, and Decoder reuse helps only partially. For the handful of hot, fixed-schema endpoints, a hand-rolled scanner over the byte stream – skipping strings you do not need with bytes.IndexByte and validating structure minimally – was 3-4x faster in our benchmarks than the stdlib, allocation-free. Do not do this for schemas you do not control; it is a maintenance commitment.

The numbers

On the ingest box, before and after:

1
2
3
                  allocs/op   GC interval under load   p99 latency
before (naive)       ~10        ~300 ms                  28 ms
after (scanner)      ~1.5       ~2 s                       4 ms

Throughput on the same hardware went from 1.2M to 3.8M events/s. The latency win is the interesting one: we did not speed up the parse so much as stop feeding the collector. When GC stops running every 300ms, the pause spikes that dominated p99 simply vanish.

Verifying you actually did it

  • go test -bench=. -benchmem and watch the allocs/op column. Zero means the compiler’s escape analysis agreed with you.
  • go build -gcflags='-m=2' prints escape analysis decisions. The phrase escapes to heap next to your hot parser function is the bug report.
  • go vet is cheap insurance against converting the code back into allocation soup in a refactor.

Production lessons

  • Zero-allocation code is brittle: index math and missing bounds checks are the failure mode, not allocation. Keep the safe, obviously-correct parser as the reference implementation and property-test the fast path against it on randomized inputs.
  • Reserve the treatment for the hottest ~10% of your code. unsafe-style parsing discipline everywhere makes a codebase unmaintainable for a win you cannot measure in the other 90%.
  • Watch the allocation profile, not just the CPU profile. pprof -alloc_space is where the GC-killing allocation source shows up; it will not be in the CPU samples.

comments powered by Disqus