Repository: pandas-dev/pandas
Pull request: PERF: Implement round on the block level
- [ ] closes #17254 (Replace xxxx with the GitHub issue number)
- [ ] [Tests added and passed](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#writing-tests) if fixing a bug or adding a new feature
- [ ] All [code checks passed](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#pre-commit).
- [ ] Added [type annotations](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#type-hints) to new arguments/methods/functions.
- [ ] Added an entry in the latest `doc/source/whatsnew/vX.X.X.rst` file if fixing a bug or adding a new feature.

Benchmarks are showing same speedup as OP.

```
       before           after         ratio
     [c133327c]       [1a2c35f5]
     <main>           <faster-round>
-            115M              72M     0.63  frame_methods.Round.peakmem_round_transposed
-      1.24±0.2ms       60.9±0.9μs     0.05  frame_methods.Round.time_round
-        863±70ms       61.2±0.4μs     0.00  frame_methods.Round.time_round_transposed

SOME BENCHMARKS HAVE CHANGED SIGNIFICANTLY.
PERFORMANCE INCREASED.
```

Problem statement:
PERF: DataFrame.round() unnecessarily slow copared to np.round()
#### Code Sample, a copy-pastable example if possible

```python
import pandas as pd
import numpy as np

np_df = np.random.randn(10000, 4000)
df = pd.DataFrame(np_df)

%timeit np.round(np_df, 2)
# 416 ms ± 10.2 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit df.round(2)
# 1.69 s ± 27.8 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

%timeit np.round(df, 2)
# 1.74 s ± 112 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
```
#### Problem description

Completely unexpected DataFrage.round() showed up as a major hotspot during profiling.
When looking at the code, we see that even when rounding the complete data frame to a given number of decimals it is split into series objects which are then rounded.

I am wondering if there is a reason not to pass the underlying data frame to numpy and do the rounding there in this case.

A quick test showed that something like this would give us the numpy performance:
```python
def faster_round(df, decimals):
    rounded = np.round(df.values, decimals)
    return pd.DataFrame(rounded, columns=df.columns, index=df.index)

%timeit faster_round(df, 2)
# 417 ms ± 14.6 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

Hints from issue discussion:
@aberres : Good question!  Ultimately, we doing up touch the `get_values` method for many `pandas` objects, which returns `ndarray`.  Perhaps we *could* re-implement to avoid all of this indirection, though be careful to ensure nothing breaks.

@jreback @jorisvandenbossche 
this is done column by column, it *could* instead be per-dtype as a block. would require some amount of work to do this. pull-requests are welcome.

Commit message: PERF: Implement round on the block level

Patch to review:
diff --git a/asv_bench/benchmarks/frame_methods.py b/asv_bench/benchmarks/frame_methods.py
index 11b30ce601be6..690f446b8d9d0 100644
--- a/asv_bench/benchmarks/frame_methods.py
+++ b/asv_bench/benchmarks/frame_methods.py
@@ -739,4 +739,22 @@ def time_memory_usage_object_dtype(self):
         self.df2.memory_usage(deep=True)
 
 
+class Round:
+    def setup(self):
+        self.df = DataFrame(np.random.randn(1000, 100))
+        self.df_t = self.df.transpose(copy=True)
+
+    def time_round(self):
+        self.df.round()
+
+    def time_round_transposed(self):
+        self.df_t.round()
+
+    def peakmem_round(self):
+        self.df.round()
+
+    def peakmem_round_transposed(self):
+        self.df_t.round()
+
+
 from .pandas_vb_common import setup  # noqa: F401 isort:skip
diff --git a/doc/source/whatsnew/v2.0.0.rst b/doc/source/whatsnew/v2.0.0.rst
index 78422ec686da8..482ddbdce6b3a 100644
--- a/doc/source/whatsnew/v2.0.0.rst
+++ b/doc/source/whatsnew/v2.0.0.rst
@@ -1153,7 +1153,7 @@ Performance improvements
 - Fixed a reference leak in :func:`read_hdf` (:issue:`37441`)
 - Fixed a memory leak in :meth:`DataFrame.to_json` and :meth:`Series.to_json` when serializing datetimes and timedeltas (:issue:`40443`)
 - Decreased memory usage in many :class:`DataFrameGroupBy` methods (:issue:`51090`)
--
+- Performance improvement in :meth:`DataFrame.round` for an integer ``decimal`` parameter (:issue:`17254`)
 
 .. ---------------------------------------------------------------------------
 .. _whatsnew_200.bug_fixes:
diff --git a/pandas/core/frame.py b/pandas/core/frame.py
index fc0a2463e7702..7921c6efa2487 100644
--- a/pandas/core/frame.py
+++ b/pandas/core/frame.py
@@ -9915,6 +9915,7 @@ def _dict_round(df: DataFrame, decimals):
                     yield _series_round(vals, decimals[col])
                 except KeyError:
                     yield vals
+            df._clear_item_cache()
 
         def _series_round(ser: Series, decimals: int) -> Series:
             if is_integer_dtype(ser.dtype) or is_float_dtype(ser.dtype):
@@ -9923,6 +9924,9 @@ def _series_round(ser: Series, decimals: int) -> Series:
 
         nv.validate_round(args, kwargs)
 
+        new_cols = None
+        new_mgr = None
+
         if isinstance(decimals, (dict, Series)):
             if isinstance(decimals, Series) and not decimals.index.is_unique:
                 raise ValueError("Index of decimals must be unique")
@@ -9932,15 +9936,19 @@ def _series_round(ser: Series, decimals: int) -> Series:
                 raise TypeError("Values in decimals must be integers")
             new_cols = list(_dict_round(self, decimals))
         elif is_integer(decimals):
-            # Dispatch to Series.round
-            new_cols = [_series_round(v, decimals) for _, v in self.items()]
+            # Dispatch to Block.round
+            new_mgr = self._mgr.apply("round", decimals=decimals)
         else:
             raise TypeError("decimals must be an integer, a dict-like or a Series")
 
-        if len(new_cols) > 0:
+        if new_cols is not None and len(new_cols) > 0:
             return self._constructor(
                 concat(new_cols, axis=1), index=self.index, columns=self.columns
             ).__finalize__(self, method="round")
+        elif new_mgr is not None:
+            return self._constructor(
+                new_mgr, index=self.index, columns=self.columns
+            ).__finalize__(self, method="round")
         else:
             return self.copy(deep=False)
 
diff --git a/pandas/core/internals/blocks.py b/pandas/core/internals/blocks.py
index 1aba48371b430..4427fd637116a 100644
--- a/pandas/core/internals/blocks.py
+++ b/pandas/core/internals/blocks.py
@@ -1397,6 +1397,22 @@ def quantile(
         result = ensure_block_shape(result, ndim=2)
         return new_block_2d(result, placement=self._mgr_locs)
 
+    def round(self, decimals) -> Block:
+        """
+        Rounds the values.
+        If the block is not of an integer or float dtype, nothing happens
+
+        Parameters
+        ----------
+        decimals: Number of decimal places to round to.
+            Caller is responsible for validating this
+        """
+        # TODO: EAs?
+        # Maybe fallback to numpy
+        if not self.is_numeric or self.is_bool:
+            return self.copy(deep=None)
+        return new_block_2d(self.values.round(decimals), placement=self._mgr_locs)
+
     # ---------------------------------------------------------------------
     # Abstract Methods Overridden By EABackedBlock and NumpyBlock
 


Code review comments:
diff_hunk: @@ -9915,6 +9915,7 @@ def _dict_round(df: DataFrame, decimals):
                     yield _series_round(vals, decimals[col])
                 except KeyError:
                     yield vals
+            df._clear_item_cache()
path: pandas/core/frame.py
text: @user1:
why is this needed?

@author:
Oops, this snuck in. I'm going to make another PR for this separately.

diff_hunk: @@ -9932,15 +9936,19 @@ def _series_round(ser: Series, decimals: int) -> Series:
                 raise TypeError("Values in decimals must be integers")
             new_cols = list(_dict_round(self, decimals))
         elif is_integer(decimals):
-            # Dispatch to Series.round
-            new_cols = [_series_round(v, decimals) for _, v in self.items()]
+            # Dispatch to Block.round
+            new_mgr = self._mgr.apply("round", decimals=decimals)
         else:
             raise TypeError("decimals must be an integer, a dict-like or a Series")
 
-        if len(new_cols) > 0:
+        if new_cols is not None and len(new_cols) > 0:
             return self._constructor(
                 concat(new_cols, axis=1), index=self.index, columns=self.columns
             ).__finalize__(self, method="round")
+        elif new_mgr is not None:
path: pandas/core/frame.py
text: @user1:
is new_mgr only assigned in one place above?  if so, why not just return there?

@author:
Thanks. updated.