<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Bijay Beezoe</title>
    <description>The latest articles on DEV Community by Bijay Beezoe (@bijay-odyssey).</description>
    <link>https://dev.to/bijay-odyssey</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4094433%2F790345b1-4764-4331-a765-f62e3ad71e95.png</url>
      <title>DEV Community: Bijay Beezoe</title>
      <link>https://dev.to/bijay-odyssey</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bijay-odyssey"/>
    <language>en</language>
    <item>
      <title>I deleted my own optimization because the benchmark said it was 2.1x slower</title>
      <dc:creator>Bijay Beezoe</dc:creator>
      <pubDate>Tue, 25 Aug 2026 15:49:42 +0000</pubDate>
      <link>https://dev.to/bijay-odyssey/i-deleted-my-own-optimization-because-the-benchmark-said-it-was-21x-slower-538k</link>
      <guid>https://dev.to/bijay-odyssey/i-deleted-my-own-optimization-because-the-benchmark-said-it-was-21x-slower-538k</guid>
      <description>&lt;p&gt;I spent a day writing a NumPy kernel to replace some pandas code. It was the good&lt;br&gt;
kind of optimisation: operate on the block, not the column. Compute the moments in&lt;br&gt;
one pass over a 2-D array instead of looping over Series. Skip the pandas&lt;br&gt;
machinery entirely.&lt;/p&gt;

&lt;p&gt;Then I benchmarked it against the pandas code it replaced, and deleted it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing I built
&lt;/h2&gt;

&lt;p&gt;The library this is from, edaprep, profiles a DataFrame before deciding how to&lt;br&gt;
preprocess it. Profiling means computing per-column statistics -- mean, standard&lt;br&gt;
deviation, skewness, kurtosis, quantiles -- over every numeric column.&lt;/p&gt;

&lt;p&gt;The obvious pandas version does that column by column. The obvious optimisation is&lt;br&gt;
to stop doing that: take the numeric columns as one contiguous 2-D array, compute&lt;br&gt;
the moments along an axis in a single vectorised pass, and hand back the results.&lt;br&gt;
One trip through memory instead of N.&lt;/p&gt;

&lt;p&gt;That reasoning is correct, and the code was faster in the microbenchmark I wrote&lt;br&gt;
while developing it. I was pleased with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The measurement
&lt;/h2&gt;

&lt;p&gt;Real benchmark, real frame shapes, candidates interleaved, minimum of five runs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;implementation&lt;/th&gt;
&lt;th&gt;time&lt;/th&gt;
&lt;th&gt;peak memory&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;hand-written NumPy block kernel&lt;/td&gt;
&lt;td&gt;799 ms&lt;/td&gt;
&lt;td&gt;298 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the pandas code it replaced&lt;/td&gt;
&lt;td&gt;557 ms&lt;/td&gt;
&lt;td&gt;1.6 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Slower, and using nearly two hundred times the memory.&lt;/p&gt;

&lt;p&gt;The memory number is the tell, and it explains the time. Getting "the numeric&lt;br&gt;
columns as one contiguous 2-D array" requires &lt;em&gt;materialising&lt;/em&gt; that array. pandas&lt;br&gt;
stores a frame as blocks; the moment your columns are not already one homogeneous&lt;br&gt;
block -- different dtypes, a nullable column, anything -- &lt;code&gt;.to_numpy()&lt;/code&gt; copies the&lt;br&gt;
entire numeric half of the frame into a fresh allocation. On a 100,000-row frame&lt;br&gt;
that is hundreds of megabytes of memcpy before a single arithmetic operation runs.&lt;/p&gt;

&lt;p&gt;pandas never pays that. It computes each column's moments against the block that&lt;br&gt;
already exists, in place, and allocates almost nothing.&lt;/p&gt;

&lt;p&gt;I had optimised the arithmetic, which was never the bottleneck, by adding a copy,&lt;br&gt;
which was.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that stung
&lt;/h2&gt;

&lt;p&gt;There was a second version. I kept a branch that chose between per-column and&lt;br&gt;
frame-level based on width, on the theory that wide frames would amortise the&lt;br&gt;
copy. The branch was inverted. On a narrow frame it picked frame-level and took&lt;br&gt;
1684 ms and 378 MiB, against 543 ms and 3.1 MiB for the per-column path.&lt;/p&gt;

&lt;p&gt;So the fix was not to correct the branch. It was to notice that the branch had no&lt;br&gt;
winning side and remove it. &lt;code&gt;profiling/statistics.py&lt;/code&gt; now delegates to pandas, and&lt;br&gt;
it is the fastest, smallest and shortest of the three versions I wrote.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it is in the docs and not just in the git history
&lt;/h2&gt;

&lt;p&gt;The temptation is to quietly delete the branch and let the README claim the good&lt;br&gt;
numbers. I put it in section 1 of &lt;code&gt;docs/performance.md&lt;/code&gt; instead, because a&lt;br&gt;
performance document that only contains wins is not evidence, it is marketing. The&lt;br&gt;
same benchmark harness that produced the wins produced this, and the fact that I&lt;br&gt;
published the one that went against me is the reason you might believe the others:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;operation&lt;/th&gt;
&lt;th&gt;edaprep&lt;/th&gt;
&lt;th&gt;baseline&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Scaler&lt;/code&gt; (standard)&lt;/td&gt;
&lt;td&gt;6.9 ms&lt;/td&gt;
&lt;td&gt;sklearn &lt;code&gt;StandardScaler&lt;/code&gt; 26.7 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;MissingValueHandler&lt;/code&gt; (median)&lt;/td&gt;
&lt;td&gt;5.2 ms&lt;/td&gt;
&lt;td&gt;sklearn &lt;code&gt;SimpleImputer&lt;/code&gt; 17.0 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;OutlierHandler&lt;/code&gt; (IQR clip)&lt;/td&gt;
&lt;td&gt;25.2 ms&lt;/td&gt;
&lt;td&gt;the usual IQR block 41.4 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The library's contributing guide now says the same thing as a rule: performance&lt;br&gt;
claims need a benchmark, and be willing to delete.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things I would tell myself a day earlier
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Benchmark the replacement against the thing it replaces&lt;/strong&gt;, on realistic
shapes, not against a strawman loop on a toy frame. My microbenchmark used a
single-dtype frame, which is the one case where &lt;code&gt;.to_numpy()&lt;/code&gt; is free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure memory alongside time.&lt;/strong&gt; The time result was ambiguous enough that I
might have argued with it. 298 MiB against 1.6 MiB is not arguable, and it
explained the time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Vectorised" is not a synonym for "fast."&lt;/strong&gt; It is a synonym for "the
arithmetic is not the bottleneck." If you had to copy the data to get there,
you moved the bottleneck rather than removing it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The library is &lt;a href="https://github.com/bijay-odyssey/edaprep" rel="noopener noreferrer"&gt;edaprep&lt;/a&gt; -- MIT, pure&lt;br&gt;
Python, &lt;code&gt;pip install edaprep&lt;/code&gt;. It does EDA and leakage-safe preprocessing, and it&lt;br&gt;
prints the reason for every decision it makes with the measurement attached, which&lt;br&gt;
is the same instinct as publishing this benchmark.&lt;/p&gt;

&lt;p&gt;There is still no compiled code in it. The one place a hand-written kernel looked&lt;br&gt;
promising was slower than pandas.&lt;/p&gt;

</description>
      <category>python</category>
      <category>performance</category>
      <category>datascience</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
