Skip to content
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
13b828f
start experimenting with parquet statistics
rjzamora May 15, 2023
f5f4e19
Merge remote-tracking branch 'upstream/main' into pq-statistics-len
rjzamora May 15, 2023
990ba4c
adopt parts of #40
rjzamora May 16, 2023
1c62f4c
experimenting with dedicated Metadata class structure
rjzamora May 16, 2023
afd59d7
add missing file
rjzamora May 16, 2023
8302305
go back to and remove sub-class for now
rjzamora May 16, 2023
a3c5f2c
add parquet test
rjzamora May 16, 2023
cbced80
use assume vs inherit
rjzamora May 16, 2023
5fe5862
use assume vs inherit
rjzamora May 16, 2023
b0946f8
split test
rjzamora May 16, 2023
bfd8710
fix doc-string
rjzamora May 16, 2023
2d343c7
fix typos
rjzamora May 16, 2023
aa27c96
Merge remote-tracking branch 'upstream/main' into pq-statistics-len
rjzamora May 17, 2023
4ce604d
use _lengths ILO statistics
rjzamora May 17, 2023
4ad6fb2
start pushing on _column_statistics
rjzamora May 18, 2023
d5e93a4
add _collect_statistics machinery to ReadParquet
rjzamora May 18, 2023
7b137c5
move utilities out of class body
rjzamora May 18, 2023
f6823d1
introduce _partitioning
rjzamora May 19, 2023
e600ea1
add simple test coverage for _partitions
rjzamora May 19, 2023
1dbfb18
improve test and fix bug
rjzamora May 19, 2023
5020657
Merge remote-tracking branch 'upstream/main' into simple-statistics
rjzamora May 19, 2023
423cfcb
remove leftover
rjzamora May 19, 2023
58ebf5a
fix parquet len test
rjzamora May 19, 2023
5790fb1
fix calculate_divisions default
rjzamora May 22, 2023
4be0221
Merge remote-tracking branch 'upstream/main' into simple-statistics
rjzamora May 23, 2023
cc01ebb
strip out _partitioning changes
rjzamora May 23, 2023
0345d19
missing calculate_divisions default
rjzamora May 23, 2023
7052a26
move _lengths to a method with force option
rjzamora May 23, 2023
e26d6cd
cache pd lengths
rjzamora May 23, 2023
5c376b9
missing annotations import
rjzamora May 23, 2023
cd6a5d6
Merge remote-tracking branch 'upstream/main' into simple-statistics
rjzamora May 24, 2023
62fbcfa
Merge remote-tracking branch 'upstream/main' into HEAD
rjzamora May 30, 2023
253cfeb
use Lengths
rjzamora May 30, 2023
1318219
Merge remote-tracking branch 'upstream/main' into simple-statistics
rjzamora May 30, 2023
32e4f94
partial fixup
rjzamora May 30, 2023
bd5395a
improve testing
rjzamora May 30, 2023
be4af18
cleanup
rjzamora May 31, 2023
47ff1d3
remove _len for now
rjzamora May 31, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion dask_expr/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from dask_expr import expr
from dask_expr.expr import no_default
from dask_expr.merge import Merge
from dask_expr.reductions import Len
from dask_expr.repartition import Repartition

#
Expand Down Expand Up @@ -73,6 +74,13 @@ def _meta(self):
def size(self):
return new_collection(self.expr.size)

def __len__(self):
return self._len

@functools.cached_property
def _len(self):
return new_collection(Len(self.expr)).compute()

@property
def nbytes(self):
raise NotImplementedError("nbytes is not implemented on DataFrame")
Expand Down Expand Up @@ -559,7 +567,7 @@ def read_parquet(
index=None,
storage_options=None,
dtype_backend=None,
calculate_divisions=False,
calculate_divisions=True,
ignore_metadata_file=False,
metadata_task_size=None,
split_row_groups="infer",
Expand Down
70 changes: 69 additions & 1 deletion dask_expr/expr.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
is_dataframe_like,
is_index_like,
is_series_like,
make_meta,
)
from dask.dataframe.dispatch import meta_nonempty
from dask.utils import M, apply, funcname, import_required, is_arraylike
Expand All @@ -39,6 +40,7 @@ class Expr:
associative = False
_parameters = []
_defaults = {}
_lengths = None

def __init__(self, *args, **kwargs):
operands = list(args)
Expand Down Expand Up @@ -616,6 +618,61 @@ def visualize(self, filename="dask-expr.svg", format=None, **kwargs):
graphviz_to_file(g, filename, format)
return g

def _partitioning(self, columns: list) -> dict:
"""Known partitioning information

Return known-partitioning information for the specified
list of columns. This information should be formatted
as a dict containing "columns" and "how" keys, where
the `"columns"` value should be a tuple of column names,
and the `"how"` value should be a tuple that uniquely
identifies the partitioning.

If no partitioning information is known, an empty
dictionary will be returned.

Examples of `"how"`:

- Sorted data: `("increasing", <divisions>)`
- Reverse-sorted data: `("decreasing", <divisions>)`
- Shuffled data: `("hash", <npartitions>)`

Note that un-named index columns must be specified as
`"__index__"` (`None` is not supported).

Return
------
partitioning: dict
"""

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd welcome a conversation about this.

My initial thought is that it made sense to store some baseline information like ...

  1. row counts of each partition
  2. min/max values of each column in each partition

These are similar to what comes out of parquet. Then, when we wanted to ask something, we would consult that raw data.

This feels like we're now storing derivative values off of that data. This makes me slightly nervous because it opens the door to tracking lots of state. I would be more comfortable if we were to track the underlying state (counts, mins, maxes) and then decided to compute quantities like these on the fly. That feels more tightly scoped to me.

Thoughts?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR does a few different things, and some of those "things" I am much more confident in than others.

  1. We add an optional _lengths attribute the Expr to store "known" partition lengths.
  2. We add an optional _partitioning method to Expr so that an expression can check if the underlying collection is partitioned by a specific set of columns (even if that column does not include an index with known divisions).
  3. We add logic to some Expr classes (mostly ReadParquet) to "lazily" collect the necessary statistics when _lengths or _partitioning information is requested.

The primary reason this PR is still marked as "draft" is that the current iteration will always attempt to go back and collect statistics in ReadParquet when _lengths or _partitioning are called (and the necessary statistics are missing). While it will always make sense to collect partition-length statistics in support of something like len(df), it may not always be the best idea to collect statistics. In fact, I'm already a bit uncomfortable with the fact that column-projection and predicate-pushdown optimizations currently require us to repeat the initial dataset processing, which can be slow on some systems (this is something I'd like to address separately).

Note that I also think the specific API can be improved, but the "eagerness" of the lazy-metadata collection feels like the most challenging short-term blocker.

What you seem to be uncomfortable with is the fact that we are not adding something like Expr._mins and Expr._maxes, but are instead exposing a method to provide more general (derivative) information about how the collection is partitioned. I'm very open to other approaches. My current proposal here was just the natural result of attempting to store mins/maxes, and finding that my personal attempt at doing so was not particularly clean or useful. In most cases, the original ReadParquet expression will not collect useful min/max statistics. When the expression does collect min/max statistics, the only reason we care about them is to tell us how/if the collection is partitioned. For this reason, I found it most natural to allow specific classes (like ReadParquet and Shuffle) to worry about what kinds of statistics they want to collect/track (if any).

I'll think a bit more about this.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm already a bit uncomfortable with the fact that column-projection and predicate-pushdown optimizations currently require us to repeat the initial dataset processing, which can be slow on some systems (this is something I'd like to address separately).

I noticed this recently. I wonder if the parquet code could benefit from a module-level lru-cache

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What you seem to be uncomfortable with is the fact that we are not adding something like Expr._mins and Expr._maxes, but are instead exposing a method to provide more general (derivative) information about how the collection is partitioned

Yeah, I'm comparing this to database world where you have a reference table which is the single point of truth (SPOT) and then views on that table. This feels like we're storing the views as concrete tables. Bad things tend to result from that behavior.

As an example. I could imagine future applications aside from sortedness. We've mentioned a couple of these including filtering / partition pruning and optimizations that are based on the values. I think that storing the underlying data is more future-proof.

I probably wouldn't have separate protocols for _maxes and _mins but maybe a protocol that includes _min_maxes or all column-based statistics (if they're likely to be consistent across all systems that provide this information (it might make sense to look at what Snowflake, Parquet, and Delta all provide, for example)).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After thinking about this a bit more, I'm planning to split this work into two distinct proposals: (1) Tracking and using partition-length statistics, and (2) tracking and using min/max statistics.

I'm expecting that we will be able to agree on a design for (1) a lot faster than (2).

I also expect (1) to be a bit more valuable than (2) in the short term. In my experience, it can be useful to know column mins/maxes immediately after IO. However, it would be much more valuable to have a _partitioning-like method/utility to tell us if a collection is partitioned by a given set of columns. I'd expect such a method to consult min/max statistics (if known), but the more-common case would be that the collection was recently shuffled/joined/grouped on the columns in question.

To summarize: I think storing/using length-based statistics is useful and easier to agree on in the short term, so I will probably focus on that first. I don't personally care much about min/max statistics unless they are in support of a _partitioning-like method. So, I'll probably hold off on that work until there is some consensus on what that API should look/behave like.

assert isinstance(columns, list), "columns must be list"

# By default, we only know about partitioning from known divisions
index_name = self._meta.index.name or "__index__"
if self.known_divisions and columns[0] == index_name:
return {
"columns": (index_name,),
"how": self.divisions,
}

return {}


class Literal(Expr):
"""Represent a literal (known) value as an `Expr`"""

_parameters = ["value"]

def _divisions(self):
return (None, None)

@property
def _meta(self):
return make_meta(self.value)

def _task(self, index: int):
assert index == 0
return self.value
Comment thread
rjzamora marked this conversation as resolved.


class Blockwise(Expr):
"""Super-class for block-wise operations
Expand Down Expand Up @@ -765,7 +822,9 @@ class Elemwise(Blockwise):
optimizations, like `len` will care about which operations preserve length
"""

pass
@property
def _lengths(self):
return self.dependencies()[0]._lengths


class AsType(Elemwise):
Expand Down Expand Up @@ -857,6 +916,9 @@ def __str__(self):
base = "(" + base + ")"
return f"{base}[{repr(self.columns)}]"

def _partitioning(self, columns: list) -> dict:
return self.frame._partitioning(columns)

def _simplify_down(self):
if isinstance(self.frame, Projection):
# df[a][b]
Expand Down Expand Up @@ -1085,6 +1147,12 @@ def _simplify_down(self):
def _node_label_args(self):
return [self.frame, self.partitions]

@property
def _lengths(self):
lengths = self.frame._lengths
if lengths:
return tuple(lengths[i] for i in self.partitions)


class PartitionsFiltered(Expr):
"""Mixin class for partition filtering
Expand Down
9 changes: 9 additions & 0 deletions dask_expr/io/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,15 @@ def _divisions_and_locations(self):
divisions = (None,) * len(locations)
return divisions, locations

@functools.cached_property
def _lengths(self):
locations = self._locations()
return tuple(
offset - locations[i]
for i, offset in enumerate(locations[1:])
if not self._filtered or i in self._partitions
)

def _divisions(self):
return self._divisions_and_locations[0]

Expand Down
Loading