为什么带有 numexpr 的 Pandas.eval() 这么慢?

Why is Pandas.eval() with numexpr so slow?

测试代码:

import numpy as np
import pandas as pd

COUNT = 1000000

df = pd.DataFrame({
    'y': np.random.normal(0, 1, COUNT),
    'z': np.random.gamma(50, 1, COUNT),
})

%timeit df.y[(10 < df.z) & (df.z < 50)].mean()
%timeit df.y.values[(10 < df.z.values) & (df.z.values < 50)].mean()
%timeit df.eval('y[(10 < z) & (z < 50)].mean()', engine='numexpr')

我的机器(相当快的 x86-64 Linux 桌面 Python 3.6)的输出是:

17.8 ms ±  1.3 ms per loop (mean ± std. dev. of 7 runs, 100 loops each)
8.44 ms ±  502 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
46.4 ms ± 2.22 ms per loop (mean ± std. dev. of 7 runs,  10 loops each)

我明白为什么第二行要快一点(它忽略了 Pandas 索引)。但是为什么使用 numexpreval() 方法这么慢?它不应该至少比第一种方法更快吗?文档确实使它看起来像:https://pandas.pydata.org/pandas-docs/stable/enhancingperf.html

从下面的调查来看,性能较差的不明显原因似乎是 "overhead"。

表达式y[(10 < z) & (z < 50)].mean()中只有一小部分是通过numexpr-模块完成的。 numexpr doesn't support indexing,因此我们只能希望 (10 < z) & (z < 50) 加速 - 任何其他内容都将映射到 pandas-operations.

然而,(10 < z) & (z < 50)并不是这里的瓶颈,可以很容易地看出:

%timeit df.y[(10 < df.z) & (df.z < 50)].mean()  # 16.7 ms
mask=(10 < df.z) & (df.z < 50)                  
%timeit df.y[mask].mean()                       # 13.7 ms
%timeit df.y[mask]                              # 13.2 ms

df.y[mask] - 占据了大部分的 运行ning 时间。

我们可以比较 df.y[mask]df.eval('y[mask]') 的分析器输出,看看有什么不同。

当我使用以下脚本时:

import numpy as np
import pandas as pd

COUNT = 1000000

df = pd.DataFrame({
    'y': np.random.normal(0, 1, COUNT),
    'z': np.random.gamma(50, 1, COUNT),
})

mask = (10 < df.z) & (df.z < 50)
df['m']=mask

for _ in range(500):
   df.y[df.m] 
   # OR 
   #df.eval('y[m]', engine='numexpr')

和运行它与python -m cProfile -s cumulative run.py(或IPython中的%prun -s cumulative <...>),我可以看到以下配置文件。

对于 pandas 功能的直接调用:

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
    419/1    0.013    0.000    7.228    7.228 {built-in method builtins.exec}
        1    0.006    0.006    7.228    7.228 run.py:1(<module>)
      500    0.005    0.000    6.589    0.013 series.py:764(__getitem__)
      500    0.003    0.000    6.475    0.013 series.py:812(_get_with)
      500    0.003    0.000    6.468    0.013 series.py:875(_get_values)
      500    0.009    0.000    6.445    0.013 internals.py:4702(get_slice)
      500    0.006    0.000    3.246    0.006 range.py:491(__getitem__)
      505    3.146    0.006    3.236    0.006 base.py:2067(__getitem__)
      500    3.170    0.006    3.170    0.006 internals.py:310(_slice)
    635/2    0.003    0.000    0.414    0.207 <frozen importlib._bootstrap>:958(_find_and_load)

我们可以看到几乎 100% 的时间都花在了 series.__getitem__ 上,没有任何开销。

对于通过df.eval(...)的调用,情况就大不相同了:

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
    453/1    0.013    0.000   12.702   12.702 {built-in method builtins.exec}
        1    0.015    0.015   12.702   12.702 run.py:1(<module>)
      500    0.013    0.000   12.090    0.024 frame.py:2861(eval)
 1000/500    0.025    0.000   10.319    0.021 eval.py:153(eval)
 1000/500    0.007    0.000    9.247    0.018 expr.py:731(__init__)
 1000/500    0.004    0.000    9.236    0.018 expr.py:754(parse)
 4500/500    0.019    0.000    9.233    0.018 expr.py:307(visit)
 1000/500    0.003    0.000    9.105    0.018 expr.py:323(visit_Module)
 1000/500    0.002    0.000    9.102    0.018 expr.py:329(visit_Expr)
      500    0.011    0.000    9.096    0.018 expr.py:461(visit_Subscript)
      500    0.007    0.000    6.874    0.014 series.py:764(__getitem__)
      500    0.003    0.000    6.748    0.013 series.py:812(_get_with)
      500    0.004    0.000    6.742    0.013 series.py:875(_get_values)
      500    0.009    0.000    6.717    0.013 internals.py:4702(get_slice)
      500    0.006    0.000    3.404    0.007 range.py:491(__getitem__)
      506    3.289    0.007    3.391    0.007 base.py:2067(__getitem__)
      500    3.282    0.007    3.282    0.007 internals.py:310(_slice)
      500    0.003    0.000    1.730    0.003 generic.py:432(_get_index_resolvers)
     1000    0.014    0.000    1.725    0.002 generic.py:402(_get_axis_resolvers)
     2000    0.018    0.000    1.685    0.001 base.py:1179(to_series)
     1000    0.003    0.000    1.537    0.002 scope.py:21(_ensure_scope)
     1000    0.014    0.000    1.534    0.002 scope.py:102(__init__)
      500    0.005    0.000    1.476    0.003 scope.py:242(update)
      500    0.002    0.000    1.451    0.003 inspect.py:1489(stack)
      500    0.021    0.000    1.449    0.003 inspect.py:1461(getouterframes)
    11000    0.062    0.000    1.415    0.000 inspect.py:1422(getframeinfo)
     2000    0.008    0.000    1.276    0.001 base.py:1253(_to_embed)
     2035    1.261    0.001    1.261    0.001 {method 'copy' of 'numpy.ndarray' objects}
     1000    0.015    0.000    1.226    0.001 engines.py:61(evaluate)
    11000    0.081    0.000    1.081    0.000 inspect.py:757(findsource)

再次在 series.__getitem__ 中花费了大约 7 秒,但也有大约 6 秒的开销 - 例如 frame.py:2861(eval) 中大约 2 秒,expr.py:461(visit_Subscript) 中大约 2 秒。

我只进行了粗略的调查(请参阅下面的更多详细信息),但这种开销似乎不仅仅是恒定的,而且至少与系列中的元素数量呈线性关系。例如有 method 'copy' of 'numpy.ndarray' objects 这意味着数据被复制(很不清楚,为什么这本身是必要的)。

我的收获:使用 pd.eval 具有优势,只要计算的表达式可以单独使用 numexpr 进行计算。一旦不是这种情况,可能就会因相当大的开销而不再是收益而是损失。


使用line_profiler(这里我使用%lp运行-magic(在用%load_ext line_profliler加载它之后)用于函数run(),它或多或少是一个副本从上面的脚本中)我们可以很容易地找到 Frame.eval:

中时间丢失的地方
%lprun -f pd.core.frame.DataFrame.eval
       -f pd.core.frame.DataFrame._get_index_resolvers 
       -f pd.core.frame.DataFrame._get_axis_resolvers  
       -f pd.core.indexes.base.Index.to_series 
       -f pd.core.indexes.base.Index._to_embed
       run()

在这里我们可以看到是否花费了额外的 10%:

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
  2861                                               def eval(self, expr, 
....
  2951        10        206.0     20.6      0.0          from pandas.core.computation.eval import eval as _eval
  2952                                           
  2953        10        176.0     17.6      0.0          inplace = validate_bool_kwarg(inplace, 'inplace')
  2954        10         30.0      3.0      0.0          resolvers = kwargs.pop('resolvers', None)
  2955        10         37.0      3.7      0.0          kwargs['level'] = kwargs.pop('level', 0) + 1
  2956        10         17.0      1.7      0.0          if resolvers is None:
  2957        10     235850.0  23585.0      9.0              index_resolvers = self._get_index_resolvers()
  2958        10       2231.0    223.1      0.1              resolvers = dict(self.iteritems()), index_resolvers
  2959        10         29.0      2.9      0.0          if 'target' not in kwargs:
  2960        10         19.0      1.9      0.0              kwargs['target'] = self
  2961        10         46.0      4.6      0.0          kwargs['resolvers'] = kwargs.get('resolvers', ()) + tuple(resolvers)
  2962        10    2392725.0 239272.5     90.9          return _eval(expr, inplace=inplace, **kwargs)

_get_index_resolvers() 可以深入到 Index._to_embed:

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
  1253                                               def _to_embed(self, keep_tz=False, dtype=None):
  1254                                                   """
  1255                                                   *this is an internal non-public method*
  1256                                           
  1257                                                   return an array repr of this object, potentially casting to object
  1258                                           
  1259                                                   """
  1260        40         73.0      1.8      0.0          if dtype is not None:
  1261                                                       return self.astype(dtype)._to_embed(keep_tz=keep_tz)
  1262                                           
  1263        40     201490.0   5037.2    100.0          return self.values.copy()

O(n)-复制发生的地方。