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
QueryWithOptionswithExperimentalRawDecode: trueat every benchmark call site. Changing only the dependency does not enable it. PlainQuery,QueryWithStats, and everyRead*API stay on the safe default, so a benchmark that continues to callclient.Single().Query(ctx, stmt)measures the +38% safe default rather than the +154% opt-in path.
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.
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.
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.
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.
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.
On the same 30-vCPU host against a 10-node instance, the three measured arms produced:
| Metric | Released client | Safe default | Opt-in fast path |
|---|---|---|---|
| Throughput | 3.77M rows/s | 5.18M rows/s (+38%) | 9.57M rows/s (+154%) |
| Allocations per row | 36.44 | 33.40 | 15.49 |
| Bytes per row | 1657 | 1317 | 290 |
mcache.refill CPU share | 7.52% | 11.78% | 2.57% |
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.
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.
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 Next | Incorrect: 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.
Do not rely long term on:
ExperimentalRawDecode field name or its continued existence;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.