Skip to content

Commit 3f70b2f

Browse files
authored
perf(spanner): optimize query result decoding (#17375)
Work in progress. Optimizes the decoding and reading of (large) result sets for Spanner. <img width="1643" height="943" alt="image" src="https://github.com/user-attachments/assets/81997b8d-f77f-4523-acb2-e44fdccb939b" />
1 parent b23bfa4 commit 3f70b2f

5 files changed

Lines changed: 292 additions & 74 deletions

File tree

‎packages/google-cloud-spanner/google/cloud/spanner_v1/_async/streamed.py‎

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -129,16 +129,39 @@ def _merge_values(self, values):
129129
decoders=self._decoders
130130
width=len(self.fields)
131131
index=len(self._current_row)
132-
forvalueinvalues:
133-
ifself._lazy_decode:
134-
self._current_row.append(value)
135-
else:
136-
self._current_row.append(_parse_nullable(value, decoders[index]))
137-
index+=1
138-
ifindex==width:
139-
self._rows.append(self._current_row)
140-
self._current_row= []
141-
index=0
132+
current_row=self._current_row
133+
rows=self._rows
134+
135+
current_row_append=current_row.append
136+
rows_append=rows.append
137+
138+
ifself._lazy_decode:
139+
forvalueinvalues:
140+
current_row_append(value)
141+
index+=1
142+
ifindex==width:
143+
rows_append(current_row)
144+
current_row= []
145+
current_row_append=current_row.append
146+
index=0
147+
else:
148+
forvalueinvalues:
149+
# Note: We manually check value.HasField("null_value") here instead of
150+
# wrapping every decoder in _parse_nullable to avoid the overhead of
151+
# an extra Python function call layer for every cell value decoded in this loop.
152+
# If the nullable check logic is updated in _parse_nullable, update this check.
153+
ifvalue.HasField("null_value"):
154+
current_row_append(None)
155+
else:
156+
current_row_append(decoders[index](value))
157+
index+=1
158+
ifindex==width:
159+
rows_append(current_row)
160+
current_row= []
161+
current_row_append=current_row.append
162+
index=0
163+
164+
self._current_row=current_row
142165

143166
@CrossSync.convert
144167
asyncdef_consume_next(self):

‎packages/google-cloud-spanner/google/cloud/spanner_v1/_helpers.py‎

Lines changed: 88 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,14 @@
1919
importdecimal
2020
importlogging
2121
importmath
22+
importoperator
2223
importthreading
2324
importtime
2425
importuuid
2526
fromcontextlibimportcontextmanager
2627

2728
fromgoogle.api_coreimportdatetime_helpers
2829
fromgoogle.api_core.exceptionsimportAborted
29-
fromgoogle.cloud._helpersimport_date_from_iso8601_date
3030
fromgoogle.protobuf.internal.enum_type_wrapperimportEnumTypeWrapper
3131
fromgoogle.protobuf.messageimportDecodeError, Message
3232
fromgoogle.protobuf.struct_pb2importListValue, Value
@@ -465,6 +465,12 @@ def _parse_value_pb(value_pb, field_type, field_name, column_info=None):
465465
return_parse_nullable(value_pb, decoder)
466466

467467

468+
_date_fromisoformat=datetime.date.fromisoformat
469+
_Decimal=decimal.Decimal
470+
_json_from_str=JsonObject.from_str
471+
_uuid_UUID=uuid.UUID
472+
473+
468474
def_get_type_decoder(field_type, field_name, column_info=None):
469475
"""Returns a function that converts a Value protobuf to cell data.
470476
@@ -489,28 +495,30 @@ def _get_type_decoder(field_type, field_name, column_info=None):
489495
"""
490496

491497
type_code=field_type.code
498+
# Note: STRING and BOOL use operator.attrgetter because direct attribute extraction
499+
# is faster in Python. Other types require type transformation, so they use lambdas.
492500
iftype_code==TypeCode.STRING:
493-
return_parse_string
501+
returnoperator.attrgetter("string_value")
494502
eliftype_code==TypeCode.BYTES:
495-
return_parse_bytes
503+
returnlambdavalue_pb: value_pb.string_value.encode("utf8")
496504
eliftype_code==TypeCode.BOOL:
497-
return_parse_bool
505+
returnoperator.attrgetter("bool_value")
498506
eliftype_code==TypeCode.INT64:
499-
return_parse_int64
507+
returnlambdavalue_pb: int(value_pb.string_value)
500508
eliftype_code==TypeCode.FLOAT64:
501509
return_parse_float
502510
eliftype_code==TypeCode.FLOAT32:
503511
return_parse_float
504512
eliftype_code==TypeCode.DATE:
505-
return_parse_date
513+
returnlambdavalue_pb: _date_fromisoformat(value_pb.string_value)
506514
eliftype_code==TypeCode.TIMESTAMP:
507515
return_parse_timestamp
508516
eliftype_code==TypeCode.NUMERIC:
509-
return_parse_numeric
517+
returnlambdavalue_pb: _Decimal(value_pb.string_value)
510518
eliftype_code==TypeCode.JSON:
511-
return_parse_json
519+
returnlambdavalue_pb: _json_from_str(value_pb.string_value)
512520
eliftype_code==TypeCode.UUID:
513-
return_parse_uuid
521+
returnlambdavalue_pb: _uuid_UUID(value_pb.string_value)
514522
eliftype_code==TypeCode.PROTO:
515523
returnlambdavalue_pb: _parse_proto(value_pb, column_info, field_name)
516524
eliftype_code==TypeCode.ENUM:
@@ -553,48 +561,81 @@ def _parse_list_value_pbs(rows, row_type):
553561
returnresult
554562

555563

556-
def_parse_string(value_pb) ->str:
557-
returnvalue_pb.string_value
558-
559-
560-
def_parse_bytes(value_pb):
561-
returnvalue_pb.string_value.encode("utf8")
562-
563-
564-
def_parse_bool(value_pb) ->bool:
565-
returnvalue_pb.bool_value
566-
567-
568-
def_parse_int64(value_pb) ->int:
569-
returnint(value_pb.string_value)
570-
571-
572564
def_parse_float(value_pb) ->float:
573-
ifvalue_pb.HasField("string_value"):
574-
returnfloat(value_pb.string_value)
575-
else:
576-
returnvalue_pb.number_value
577-
578-
579-
def_parse_date(value_pb):
580-
return_date_from_iso8601_date(value_pb.string_value)
565+
# Note: Storing val = value_pb.string_value and doing a truthiness check is faster
566+
# than calling value_pb.HasField("string_value") because it avoids the C-extension
567+
# method lookup/call overhead and accesses the attribute only once.
568+
val=value_pb.string_value
569+
returnfloat(val) ifvalelsevalue_pb.number_value
570+
571+
572+
_POWERS_OF_10= (
573+
1,
574+
10,
575+
100,
576+
1000,
577+
10000,
578+
100000,
579+
1000000,
580+
10000000,
581+
100000000,
582+
1000000000,
583+
)
581584

582585

583586
def_parse_timestamp(value_pb):
584-
DatetimeWithNanoseconds=datetime_helpers.DatetimeWithNanoseconds
585-
returnDatetimeWithNanoseconds.from_rfc3339(value_pb.string_value)
586-
587-
588-
def_parse_numeric(value_pb):
589-
returndecimal.Decimal(value_pb.string_value)
590-
591-
592-
def_parse_json(value_pb):
593-
returnJsonObject.from_str(value_pb.string_value)
594-
595-
596-
def_parse_uuid(value_pb):
597-
returnuuid.UUID(value_pb.string_value)
587+
val=value_pb.string_value
588+
try:
589+
iflen(val) <20orval[10] !="T":
590+
raiseValueError()
591+
no_fraction=val[:19]
592+
bare=datetime.datetime.fromisoformat(no_fraction)
593+
ifval[19] ==".":
594+
ifval.endswith("Z"):
595+
offset="Z"
596+
fraction=val[20:-1]
597+
elifval[-6] in ("+", "-"):
598+
offset=val[-6:]
599+
fraction=val[20:-6]
600+
else:
601+
raiseValueError()
602+
ifnotfractionorlen(fraction) >9ornotfraction.isdigit():
603+
raiseValueError()
604+
scale=9-len(fraction)
605+
nanos=int(fraction) *_POWERS_OF_10[scale]
606+
else:
607+
nanos=0
608+
ifval.endswith("Z"):
609+
offset="Z"
610+
elifval[-6] in ("+", "-"):
611+
offset=val[-6:]
612+
else:
613+
raiseValueError()
614+
615+
ifoffset!="Z":
616+
sign=offset[0]
617+
hours=int(offset[1:3])
618+
minutes=int(offset[4:6])
619+
ifoffset[3] !=":":
620+
raiseValueError()
621+
delta=datetime.timedelta(hours=hours, minutes=minutes)
622+
ifsign=="-":
623+
delta=-delta
624+
tzinfo=datetime.timezone(delta)
625+
bare=bare.replace(tzinfo=tzinfo).astimezone(datetime.timezone.utc)
626+
627+
returndatetime_helpers.DatetimeWithNanoseconds(
628+
bare.year,
629+
bare.month,
630+
bare.day,
631+
bare.hour,
632+
bare.minute,
633+
bare.second,
634+
nanosecond=nanos,
635+
tzinfo=datetime.timezone.utc,
636+
)
637+
except (IndexError, ValueError) ase:
638+
raiseValueError("Timestamp: {} does not match pattern".format(val)) frome
598639

599640

600641
def_parse_proto(value_pb, column_info, field_name):

‎packages/google-cloud-spanner/google/cloud/spanner_v1/data_types.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,11 @@ def serialize(self):
9999
returnjson.dumps(self, sort_keys=True, separators=(",", ":"))
100100

101101

102+
_INTERVAL_PATTERN=re.compile(
103+
r"^P(-?\d+Y)?(-?\d+M)?(-?\d+D)?(T(-?\d+H)?(-?\d+M)?(-?((\d+([.,]\d{1,9})?)|([.,]\d{1,9}))S)?)?$"
104+
)
105+
106+
102107
@dataclass
103108
classInterval:
104109
"""Represents a Spanner INTERVAL type.
@@ -187,8 +192,7 @@ def __str__(self) -> str:
187192
@classmethod
188193
deffrom_str(cls, s: str) ->"Interval":
189194
"""Parse an ISO8601 duration format string into an Interval."""
190-
pattern=r"^P(-?\d+Y)?(-?\d+M)?(-?\d+D)?(T(-?\d+H)?(-?\d+M)?(-?((\d+([.,]\d{1,9})?)|([.,]\d{1,9}))S)?)?$"
191-
match=re.match(pattern, s)
195+
match=_INTERVAL_PATTERN.match(s)
192196
ifnotmatchorlen(s) ==1:
193197
raiseValueError(f"Invalid interval format: {s}")
194198

‎packages/google-cloud-spanner/google/cloud/spanner_v1/streamed.py‎

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,7 @@ class StreamedResultSet(object):
3535
instances.
3636
3737
:type source: :class:`~google.cloud.spanner_v1.snapshot.Snapshot`
38-
:param source: Deprecated. Snapshot from which the result set was fetched.
39-
"""
38+
:param source: Deprecated. Snapshot from which the result set was fetched."""
4039

4140
def__init__(
4241
self,
@@ -117,16 +116,36 @@ def _merge_values(self, values):
117116
decoders=self._decoders
118117
width=len(self.fields)
119118
index=len(self._current_row)
120-
forvalueinvalues:
121-
ifself._lazy_decode:
122-
self._current_row.append(value)
123-
else:
124-
self._current_row.append(_parse_nullable(value, decoders[index]))
125-
index+=1
126-
ifindex==width:
127-
self._rows.append(self._current_row)
128-
self._current_row= []
129-
index=0
119+
current_row=self._current_row
120+
rows=self._rows
121+
current_row_append=current_row.append
122+
rows_append=rows.append
123+
ifself._lazy_decode:
124+
forvalueinvalues:
125+
current_row_append(value)
126+
index+=1
127+
ifindex==width:
128+
rows_append(current_row)
129+
current_row= []
130+
current_row_append=current_row.append
131+
index=0
132+
else:
133+
forvalueinvalues:
134+
# Note: We manually check value.HasField("null_value") here instead of
135+
# wrapping every decoder in _parse_nullable to avoid the overhead of
136+
# an extra Python function call layer for every cell value decoded in this loop.
137+
# If the nullable check logic is updated in _parse_nullable, update this check.
138+
ifvalue.HasField("null_value"):
139+
current_row_append(None)
140+
else:
141+
current_row_append(decoders[index](value))
142+
index+=1
143+
ifindex==width:
144+
rows_append(current_row)
145+
current_row= []
146+
current_row_append=current_row.append
147+
index=0
148+
self._current_row=current_row
130149

131150
def_consume_next(self):
132151
"""Consume the next partial result set from the stream.

0 commit comments

Comments
 (0)