Experimental low-allocation Spanner decoding

This branch is a customer trial for Go applications whose Spanner reads are limited by allocation count or protobuf decode CPU. It is not a released or supported API.

Required to test the fast path: use QueryWithOptions with ExperimentalRawDecode: true at every benchmark call site. Changing only the dependency does not enable it. Plain Query, QueryWithStats, and every Read* API stay on the safe default, so a benchmark that continues to call client.Single().Query(ctx, stmt) measures the +38% safe default rather than the +154% opt-in path.

What changes

All RPCs made by the Spanner data client use vtprotobuf at the gRPC connection level for both request marshaling and response unmarshaling. This includes session RPCs, unary calls such as ExecuteSql and Commit, and streaming calls such as StreamingRead and ExecuteStreamingSql.

The connection codec keeps the normal protobuf wire content subtype. Messages generated with vtprotobuf use MarshalVT and UnmarshalVT; other protobuf messages use the standard reflection implementation. Admin clients, long-running-operation polling, and connections owned by other clients are not changed.

Use this branch from another module

From your module, run:

go get cloud.google.com/go/spanner@spanner-decode-perf
go mod tidy
go list -m cloud.google.com/go/spanner

The final command must print:

cloud.google.com/go/spanner v1.94.1-0.20260803090513-da1e076ac0bc

This output confirms which client version is in the build. If it differs, stop before benchmarking. A branch name is a moving target; for a reproducible build, use the explicit pin instead:

go get cloud.google.com/go/spanner@v1.94.1-0.20260803090513-da1e076ac0bc

Commit both go.mod and go.sum used for the trial.

Two decode modes

Safe default

No application change is needed. Use normal Read, Query, DML, and transaction APIs.

The default codec calls UnmarshalVT, which copies strings and bytes out of gRPC receive buffers. Rows, ColumnValue results, and decoded Go strings keep normal client semantics: callers may retain them after another Next, after Stop, or after the iterator is gone.

This mode primarily reduces protobuf decode CPU. It intentionally keeps copy allocations needed for ordinary Go ownership. In the verified 800-string decode benchmark on this branch, safe vtprotobuf was 57.97% faster than reflection while allocations per operation changed from 2,412 to 2,411.

Opt-in fast query path

Changing the dependency is not enough. QueryWithOptions is required to reach the fast path. Enable the existing query option at every benchmark call site:

iter := client.Single().QueryWithOptions(ctx, stmt, spanner.QueryOptions{
    ExperimentalRawDecode: true,
})
defer iter.Stop()

This mode uses UnmarshalVTUnsafe, pooled PartialResultSet and structpb.Value objects, and a row reused by RowIterator.Next. It removes string copies and recycles the receive representation. The verified 800-string benchmark used 1.000 scalar oneof allocation per column instead of 3.015 allocations per column on the stock path.

API coverage

ExperimentalRawDecode exists only on QueryOptions and is wired only through the SQL query path. The opt-in fast path applies to result rows returned by QueryWithOptions when ExperimentalRawDecode is true.

There is no corresponding field on ReadOptions. Read, ReadWithOptions, ReadUsingIndex, ReadRow, ReadRowWithOptions, and ReadRowUsingIndex remain on the safe default. Plain Query and QueryWithStats also remain on the safe default because they do not enable this option.

Verified end-to-end results

On the same 30-vCPU host against a 10-node instance, the three measured arms produced:

MetricReleased clientSafe defaultOpt-in fast path
Throughput3.77M rows/s5.18M rows/s (+38%)9.57M rows/s (+154%)
Allocations per row36.4433.4015.49
Bytes per row16571317290
mcache.refill CPU share7.52%11.78%2.57%

Read consistency coverage

The verification runs for this branch used strong reads. The stale-read arm failed on the benchmark rig with Bad BeginTransaction request under multiplexed sessions, so the stale-read path is unverified on this branch. If your workload uses stale reads, please report the behavior and measurements you observe.

Memory coverage

The opt-in path raised end-of-run resident memory roughly 2x because receive buffers stayed pinned. Peak resident memory stayed within ~3% of the released client on the measured host. Monitor both peak and steady-state resident memory in your environment.

Opt-in lifetime contract

A row returned by the opt-in path, its ColumnValue objects, and strings decoded from it are valid only until the next call to Next or Stop, whichever comes first. The next call may overwrite pooled objects and gRPC receive memory. Retaining any of them without copying can silently change data.

Correct: consume or copy before NextIncorrect: retain aliases past Next
var names []string
for {
row, err := iter.Next()
if err == iterator.Done {
break
}
if err != nil {
return err
}

var name string
if err := row.Column(0, &name); err != nil {
return err
}
// Clone while data is valid.
names = append(names, strings.Clone(name))
}
var rows []*spanner.Row
var names []string
for {
row, err := iter.Next()
if err == iterator.Done {
break
}
if err != nil {
return err
}

var name string
_ = row.Column(0, &name)
rows = append(rows, row) // Row is reused
names = append(names, name) // aliases memory
}
// Data may now be overwritten.

Copy every retained string with strings.Clone before calling Next. Materialize and deep-copy retained protobuf values. Do not pass the row to another goroutine unless that goroutine finishes before the next Next call.

If changing ownership patterns is risky, stay on the safe default.

Experimental limits

Do not rely long term on:

  • the ExperimentalRawDecode field name or its continued existence;
  • row reuse, pooling details, allocation counts, or benchmark ratios;
  • generated vtprotobuf methods being part of the public Spanner API;
  • this branch receiving release support, compatibility guarantees, or security updates.

The safe default preserves public data-lifetime semantics, but the codec implementation itself is still a branch experiment. Re-test workload correctness and performance before moving to any later commit or released implementation.