Skip to content

Commit 56f00e1

Browse files
authored
Merge pull request #1692 from no23reason/dho/cq-2677-resources-2
perf(gooddata-pandas): speed up and slim down DataFrame conversion
2 parents 807d829 + 295b9e9 commit 56f00e1

6 files changed

Lines changed: 256 additions & 54 deletions

File tree

packages/gooddata-pandas/src/gooddata_pandas/arrow_convertor.py

Lines changed: 88 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,16 @@
44
import logging
55
from typing import Callable
66

7+
import numpy
78
import orjson
89
import pandas
9-
from gooddata_sdk.type_converter import AttributeConverterStore
10+
from gooddata_sdk.type_converter import AttributeConverterStore, DateConverter, DatetimeConverter
1011

1112
from gooddata_pandas.arrow_types import TypesMapper
1213

1314
try:
1415
import pyarrow as pa
16+
import pyarrow.compute as pc
1517
except ImportError as _exc:
1618
raise ImportError(
1719
"pyarrow is required for Arrow support. Install it with: pip install gooddata-pandas[arrow]"
@@ -96,9 +98,30 @@ def convert_label_values(label_id: str, values: list, model_labels: dict) -> lis
9698
Returns:
9799
Converted list, or the original *values* object when no conversion is needed.
98100
"""
101+
# pick the converter for this label's granularity (None for plain text attributes)
99102
converter = _get_date_converter_for_label(label_id, model_labels)
100103
if converter is None:
101104
return values
105+
106+
if isinstance(converter, (DateConverter, DatetimeConverter)):
107+
# Date/datetime granularity is costly, so vectorize it in three steps:
108+
109+
# 1) parse each raw string into a datetime.date/datetime with the cheap
110+
# per-value to_type() (handles partial dates like "2023" or "2023-01");
111+
# keep None as None. This step stays per-value so that pandas
112+
# does not re-infer the date format from the raw strings in step 2.
113+
typed = [converter.to_type(v) if v is not None else None for v in values]
114+
115+
# 2) convert the whole column to Timestamps in one vectorized call (this
116+
# single call replaces N per-value ones; None becomes NaT here).
117+
converted = pandas.to_datetime(typed)
118+
119+
# 3) rebuild the list, restoring None wherever the input was None so that NaT
120+
# does not leak into the output.
121+
return [None if orig is None else c for orig, c in zip(values, converted)]
122+
123+
# WEEK / QUARTER (StringConverter) and integer granularities are cheap per value
124+
# and could change type subtly if batched, so convert them one by one (None kept).
102125
return [converter.to_external_type(v) if v is not None else None for v in values]
103126

104127

@@ -420,16 +443,23 @@ def reorder_grand_totals(
420443
return table
421444
if _COL_ROW_TYPE not in table.schema.names:
422445
return table
423-
row_type_vals = table.column(_COL_ROW_TYPE).to_pylist()
424-
grand_mask = pa.array([v == 2 for v in row_type_vals], type=pa.bool_())
425-
grand_total_rows = table.filter(grand_mask)
446+
447+
# uses pyarrow.compute to run these in a vectorized way.
448+
# the fill_null ensures that nulls are still marked as non-totals.
449+
# (pyarrow.compute functions are generated at runtime, so ty cannot see them.)
450+
row_type_col = table.column(_COL_ROW_TYPE)
451+
is_grand_total = pc.fill_null(pc.equal(row_type_col, 2), False) # ty: ignore[unresolved-attribute]
452+
grand_total_rows = table.filter(is_grand_total)
426453
if grand_total_rows.num_rows == 0:
427454
return table
428-
data_and_sub_rows = table.filter(pa.array([v != 2 for v in row_type_vals], type=pa.bool_()))
429-
return pa.concat_tables([grand_total_rows, data_and_sub_rows])
455+
456+
not_grand_total = pc.fill_null(pc.not_equal(row_type_col, 2), True) # ty: ignore[unresolved-attribute]
457+
return pa.concat_tables([grand_total_rows, table.filter(not_grand_total)])
430458

431459

432-
def compute_column_totals_indexes(table: pa.Table, execution_dims: list) -> list[list[int]]:
460+
def compute_column_totals_indexes(
461+
table: pa.Table, execution_dims: list, schema_meta: dict | None = None
462+
) -> list[list[int]]:
433463
"""
434464
Compute column_totals_indexes compatible with DataFrameMetadata from an Arrow table.
435465
@@ -445,7 +475,9 @@ def compute_column_totals_indexes(table: pa.Table, execution_dims: list) -> list
445475
grand_total_* fields become output rows and are already covered by
446476
compute_row_totals_indexes. Returns [] in that case.
447477
"""
448-
schema_meta = _parse_schema_metadata(table)
478+
if schema_meta is None:
479+
schema_meta = _parse_schema_metadata(table)
480+
449481
xtab_meta = schema_meta[_META_XTAB]
450482
is_transposed = schema_meta[_META_VIEW]["isTransposed"]
451483

@@ -518,7 +550,9 @@ def _label_ids_in_dim(dim: dict) -> set:
518550
return result
519551

520552

521-
def compute_row_totals_indexes(table: pa.Table, execution_dims: list) -> list[list[int]]:
553+
def compute_row_totals_indexes(
554+
table: pa.Table, execution_dims: list, schema_meta: dict | None = None
555+
) -> list[list[int]]:
522556
"""
523557
Compute row_totals_indexes compatible with DataFrameMetadata from an Arrow table.
524558
@@ -534,7 +568,9 @@ def compute_row_totals_indexes(table: pa.Table, execution_dims: list) -> list[li
534568
Total rows are grand_total_N fields. A field is total at level j only when
535569
j >= len(gdc["label_values"]), i.e. that label level is being aggregated.
536570
"""
537-
schema_meta = _parse_schema_metadata(table)
571+
if schema_meta is None:
572+
schema_meta = _parse_schema_metadata(table)
573+
538574
xtab_meta = schema_meta[_META_XTAB]
539575
is_transposed = schema_meta[_META_VIEW]["isTransposed"]
540576

@@ -608,8 +644,8 @@ def _label_ids_in_dim(dim: dict) -> set:
608644
else:
609645
# Output rows are Arrow rows; every total row (row_type != 0) is listed
610646
# in the total-indexes for every attribute level.
611-
row_types = _get_row_types(table)
612-
total_row_idxs = [i for i, rt in enumerate(row_types) if rt != 0]
647+
row_types = table.column(_COL_ROW_TYPE).to_numpy(zero_copy_only=False)
648+
total_row_idxs = numpy.nonzero(row_types != 0)[0].tolist()
613649

614650
result = []
615651
for header in row_dim.get("headers", []):
@@ -643,30 +679,47 @@ def _compute_primary_labels_from_inline(
643679
"""
644680
result: dict[int, dict[str, str]] = {}
645681
label_meta = xtab_meta.get("labelMetadata", {})
646-
row_types = _get_row_types(table)
647-
data_row_mask = [rt == 0 for rt in row_types]
648682

649-
for j, ref in enumerate(label_refs):
683+
# Project to only the columns this function reads - __row_type plus the label
684+
# and primary-label columns - before filtering. Filtering the whole table would
685+
# also copy every metric column for the data rows even though only these
686+
# attribute columns are ever read below.
687+
# table.select is zero-copy, so the subsequent filter copies just these columns.
688+
needed_cols = [_COL_ROW_TYPE]
689+
for ref in label_refs:
650690
info = label_meta.get(ref, {})
651691
label_id = label_ref_to_id.get(ref, info.get("labelId", ""))
652692
primary_label_id = info.get("primaryLabelId", label_id)
693+
for col in (label_id, primary_label_id):
694+
if col in table.schema.names and col not in needed_cols:
695+
needed_cols.append(col)
696+
697+
projected = table.select(needed_cols)
698+
699+
# Extract the data rows (row_type == 0) once and reuse. Only filter when totals
700+
# are actually present - otherwise the projected table already is the data rows.
701+
# (pyarrow.compute functions are generated at runtime, so ty cannot see them.)
702+
row_type_col = projected.column(_COL_ROW_TYPE)
703+
has_total_rows = pc.any(pc.not_equal(row_type_col, 0)).as_py() # ty: ignore[unresolved-attribute]
704+
data_row_mask = pc.equal(row_type_col, 0) # ty: ignore[unresolved-attribute]
705+
data_rows = projected.filter(data_row_mask) if has_total_rows else projected
653706

654-
display_vals = table.column(label_id).to_pylist()
707+
for j, ref in enumerate(label_refs):
708+
info = label_meta.get(ref, {})
709+
label_id = label_ref_to_id.get(ref, info.get("labelId", ""))
710+
primary_label_id = info.get("primaryLabelId", label_id)
655711

656-
if label_id == primary_label_id:
712+
if label_id == primary_label_id or primary_label_id not in table.schema.names:
713+
# identity (or fallback when the primary column is absent): map each
714+
# distinct display value to itself.
657715
mapping: dict[str, str] = {
658-
v: v for v, is_data in zip(display_vals, data_row_mask) if is_data and isinstance(v, str)
659-
}
660-
elif primary_label_id in table.schema.names:
661-
primary_vals = table.column(primary_label_id).to_pylist()
662-
mapping = {
663-
p: d
664-
for p, d, is_data in zip(primary_vals, display_vals, data_row_mask)
665-
if is_data and isinstance(p, str) and isinstance(d, str)
716+
v: v for v in data_rows.column(label_id).unique().to_pylist() if isinstance(v, str)
666717
}
667718
else:
668-
# Fallback: identity (primary label data not present in table)
669-
mapping = {v: v for v, is_data in zip(display_vals, data_row_mask) if is_data and isinstance(v, str)}
719+
# primary != display: map each primary value to its display value
720+
primary_vals = data_rows.column(primary_label_id).to_pylist()
721+
display_vals = data_rows.column(label_id).to_pylist()
722+
mapping = {p: d for p, d in zip(primary_vals, display_vals) if isinstance(p, str) and isinstance(d, str)}
670723

671724
result[j] = mapping
672725
return result
@@ -709,6 +762,7 @@ def _compute_primary_labels_from_fields(
709762

710763
def compute_primary_labels(
711764
table: pa.Table,
765+
schema_meta: dict | None = None,
712766
) -> tuple[dict[int, dict[str, str]], dict[int, dict[str, str]]]:
713767
"""
714768
Compute primary_labels_from_index and primary_labels_from_columns from an Arrow table.
@@ -719,7 +773,9 @@ def compute_primary_labels(
719773
Returns:
720774
(primary_labels_from_index, primary_labels_from_columns)
721775
"""
722-
schema_meta = _parse_schema_metadata(table)
776+
if schema_meta is None:
777+
schema_meta = _parse_schema_metadata(table)
778+
723779
xtab_meta = schema_meta[_META_XTAB]
724780
is_transposed = schema_meta[_META_VIEW]["isTransposed"]
725781

@@ -750,6 +806,7 @@ def convert_arrow_table_to_dataframe(
750806
types_mapper: TypesMapper = TypesMapper.DEFAULT,
751807
custom_mapping: dict | None = None,
752808
label_overrides: dict | None = None,
809+
schema_meta: dict | None = None,
753810
) -> pandas.DataFrame:
754811
"""
755812
Convert a pyarrow Table returned by the GoodData /binary execution endpoint
@@ -800,7 +857,9 @@ def convert_arrow_table_to_dataframe(
800857
else:
801858
raise ValueError("Unknown types_mapper value")
802859

803-
schema_meta = _parse_schema_metadata(table)
860+
if schema_meta is None:
861+
schema_meta = _parse_schema_metadata(table)
862+
804863
xtab_meta = schema_meta[_META_XTAB]
805864
model_meta = schema_meta[_META_MODEL]
806865
is_transposed = schema_meta[_META_VIEW]["isTransposed"]

packages/gooddata-pandas/src/gooddata_pandas/data_access.py

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
_str_to_obj_id,
2626
_to_attribute,
2727
_to_item,
28-
_typed_attribute_value,
28+
_typed_attribute_values,
2929
get_catalog_attributes_for_extract,
3030
)
3131

@@ -340,22 +340,21 @@ def _find_attribute(attributes: list[CatalogAttribute], id_obj: IdObjType) -> Un
340340
return None
341341

342342

343-
def _typed_result(attributes: list[CatalogAttribute], attribute: Attribute, result_values: list[Any]) -> list[Any]:
343+
def _resolve_catalog_attribute(attributes: list[CatalogAttribute], attribute: Attribute) -> CatalogAttribute:
344344
"""
345-
Internal function to convert result_values to proper data types.
345+
Find the CatalogAttribute matching the given execution attribute.
346346
347347
Args:
348348
attributes (list[CatalogAttribute]): The catalog of attributes.
349-
attribute (Attribute): The attribute for which the typed result will be computed.
350-
result_values (list[Any]): A list of raw values.
349+
attribute (Attribute): The execution attribute to resolve.
351350
352351
Returns:
353-
list[Any]: A list of converted values with proper data types.
352+
CatalogAttribute: The matching catalog attribute.
354353
"""
355354
catalog_attribute = _find_attribute(attributes, attribute.label)
356355
if catalog_attribute is None:
357356
raise ValueError(f"Unable to find attribute {attribute.label} in catalog")
358-
return [_typed_attribute_value(catalog_attribute, value) for value in result_values]
357+
return catalog_attribute
359358

360359

361360
def _extract_from_attributes_and_maybe_metrics(
@@ -399,20 +398,28 @@ def _extract_from_attributes_and_maybe_metrics(
399398
index_to_attribute = {index_name: exec_def.attributes[i] for index_name, i in safe_index_to_attr_idx.items()}
400399
col_to_attribute = {col: exec_def.attributes[i] for col, i in col_to_attr_idx.items()}
401400

401+
# resolve the matching CatalogAttribute for each index / attribute column once:
402+
# it does not change during the batch iteration
403+
index_to_catalog_attribute = {
404+
index_name: _resolve_catalog_attribute(attributes, attribute)
405+
for index_name, attribute in index_to_attribute.items()
406+
}
407+
col_to_catalog_attribute = {
408+
col: _resolve_catalog_attribute(attributes, attribute) for col, attribute in col_to_attribute.items()
409+
}
410+
402411
# datastructures to return
403412
index: dict[str, list[Any]] = {idx_name: [] for idx_name in safe_index_to_attr_idx}
404413
data: dict[str, list[Any]] = {col: [] for col in cols}
405414

406415
while True:
407416
for idx_name in index:
408417
rs = result.get_all_header_values(attribute_dim, safe_index_to_attr_idx[idx_name])
409-
attribute = index_to_attribute[idx_name]
410-
index[idx_name] += _typed_result(attributes, attribute, rs)
418+
index[idx_name] += _typed_attribute_values(index_to_catalog_attribute[idx_name], rs)
411419
for col in cols:
412420
if col in col_to_attr_idx:
413421
rs = result.get_all_header_values(attribute_dim, col_to_attr_idx[col])
414-
attribute = col_to_attribute[col]
415-
data[col] += _typed_result(attributes, attribute, rs)
422+
data[col] += _typed_attribute_values(col_to_catalog_attribute[col], rs)
416423
elif col_to_metric_idx[col] < len(result.data):
417424
data[col] += result.data[col_to_metric_idx[col]]
418425
if result.is_complete(attribute_dim):
@@ -455,11 +462,11 @@ def _extract_from_arrow(
455462
metric_dim_idx_to_field = build_metric_field_index(table)
456463
model_labels = read_model_labels(table)
457464

458-
data: dict[str, list] = {}
465+
data: dict[str, Any] = {}
459466
for col in cols:
460467
if col in col_to_metric_idx:
461468
field_name = metric_dim_idx_to_field[col_to_metric_idx[col]]
462-
data[col] = table.column(field_name).to_pylist()
469+
data[col] = table.column(field_name).to_numpy(zero_copy_only=False)
463470
else:
464471
attr = exec_def.attributes[col_to_attr_idx[col]]
465472
label_id = attr.label.id

packages/gooddata-pandas/src/gooddata_pandas/dataframe.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525

2626
try:
2727
from gooddata_pandas.arrow_convertor import (
28+
_parse_schema_metadata,
2829
compute_column_totals_indexes,
2930
compute_primary_labels,
3031
compute_row_totals_indexes,
@@ -469,16 +470,18 @@ def _table_to_df_and_metadata(
469470
call site.
470471
"""
471472
table = reorder_grand_totals(table, grand_totals_position)
473+
schema_meta = _parse_schema_metadata(table)
472474
df = convert_arrow_table_to_dataframe(
473475
table,
474476
self_destruct=self._arrow_config.self_destruct,
475477
types_mapper=self._arrow_config.types_mapper,
476478
custom_mapping=self._arrow_config.custom_mapping,
477479
label_overrides=label_overrides,
480+
schema_meta=schema_meta,
478481
)
479-
row_totals_indexes = compute_row_totals_indexes(table, exec_response.dimensions)
480-
column_totals_indexes = compute_column_totals_indexes(table, exec_response.dimensions)
481-
primary_labels_from_index, primary_labels_from_columns = compute_primary_labels(table)
482+
row_totals_indexes = compute_row_totals_indexes(table, exec_response.dimensions, schema_meta=schema_meta)
483+
column_totals_indexes = compute_column_totals_indexes(table, exec_response.dimensions, schema_meta=schema_meta)
484+
primary_labels_from_index, primary_labels_from_columns = compute_primary_labels(table, schema_meta=schema_meta)
482485
metadata = DataFrameMetadata(
483486
row_totals_indexes=row_totals_indexes,
484487
column_totals_indexes=column_totals_indexes,
@@ -588,22 +591,28 @@ def for_arrow_table(
588591
label_overrides = {}
589592

590593
table = reorder_grand_totals(table, grand_totals_position)
594+
# parse the schema metadata once and share it across the sibling functions
595+
# below; each of them would otherwise re-parse it from the table
596+
schema_meta = _parse_schema_metadata(table)
591597
df = convert_arrow_table_to_dataframe(
592598
table,
593599
self_destruct=self._arrow_config.self_destruct,
594600
types_mapper=self._arrow_config.types_mapper,
595601
custom_mapping=self._arrow_config.custom_mapping,
596602
label_overrides=label_overrides,
603+
schema_meta=schema_meta,
597604
)
598605
row_totals_indexes = (
599-
compute_row_totals_indexes(table, execution_response.dimensions) if execution_response is not None else []
606+
compute_row_totals_indexes(table, execution_response.dimensions, schema_meta=schema_meta)
607+
if execution_response is not None
608+
else []
600609
)
601610
column_totals_indexes = (
602-
compute_column_totals_indexes(table, execution_response.dimensions)
611+
compute_column_totals_indexes(table, execution_response.dimensions, schema_meta=schema_meta)
603612
if execution_response is not None
604613
else []
605614
)
606-
primary_labels_from_index, primary_labels_from_columns = compute_primary_labels(table)
615+
primary_labels_from_index, primary_labels_from_columns = compute_primary_labels(table, schema_meta=schema_meta)
607616
metadata = DataFrameMetadata(
608617
row_totals_indexes=row_totals_indexes,
609618
column_totals_indexes=column_totals_indexes,

0 commit comments

Comments
 (0)