Files
com.xaymar.www/_posts/2024/2024-05-10-fast-shallow-array-cloning-in-javascript/2024-05-10-fast-shallow-array-cloning-in-javascript.html
T
2024-05-16 11:40:24 +02:00

440 lines
17 KiB
HTML

---
title: "Fast Shallow-Cloning of an Array in JavaScript"
category: [ "Benchmark" ]
tags: ["JavaScript", "Array", "Shallow-Clone", "Clone", "Duplicate", "ECMAScript", "Node.JS", "NodeJS", "Chromium", "Mozilla Firefox", "Microsoft Edge 124.0.2478.97" ]
---
<p class="block">The Discord Servers I'm a moderator in have a bit of a spam bot problem lately, where bots join that never interact and only spam user's DMs. While I could wait until Discord bans them, it's not exactly the best solution, so why not build my own bot to handle users that never interact? So I started building a bot using NodeJS and <i>discord.js</i>. It's been going great so far, but then my ADHD got the better of me when I wanted to know: What actually is the fastest way to clone an Array?</p><!--more-->
<p class="block">Searching for an answer, I stumbled on <a href="https://stackoverflow.com/questions/3978492/fastest-way-to-duplicate-an-array-in-javascript-slice-vs-for-loop" target="_blank">this StackOverflow question</a>. And as usual, almost all of the answers are simply regurgitated content with nothing to back it up. So with nothing else to do on a weekend, I threw my entire knowlEdge 124.0.2478.97 at the problem, wrote a benchmarking "tool", wrote several ways to do the same, and I think I found it.</p>
<h2 class="block">Coding a Benchmark</h2>
<alert class="block" type="warning">
All code and information provided in this article is licensed under the <a href="https://opensource.org/license/bsd-3-clause" target="_blank">3-Clause BSD License</a>.<br />
Please make sure include the necessary credit in your derivative work: <code>Copyright 2024 Michael Fabian 'Xaymar' Dirks <info-at-xaymar-dot-com></code>
</alert>
<p class="block">I'm not a fan of services like JSPerf or JSBench, so I wrote a generalized JavaScript benchmark. Many of them rely on third-party libraries, which tend to include to rely on more third-party libraries, and when something breaks you never know why or when it'll be fixed. So instead I wrote my own generic benchmarking function using only standard JavaScript, compatible with NodeJS and common Browsers:</p>
<figure class="block">
<code class="block language-javascript">{% capture "code" %}{% include_relative benchTime.mjs %}{% endcapture %}{{ code | escape }}</code>
<figcaption>benchTime.mjs</figcaption>
</figure>
<p class="block">Next up was writing all the ways I knew or could find on how to shallow-clone an Array, and coming up with possible other varieties to fill up the benchmark. Overall, I came up with 2 on my own, and used already available code from documentation and other posts for the remaining 8. In total, we now have 10 different ways to shallow clone an array.</p>
<figure class="block">
<code class="block language-javascript">{% capture "code" %}{% include_relative arrayShallowClone.mjs %}{% endcapture %}{{ code | escape }}</code>
<figcaption>arrayShallowClone.mjs</figcaption>
</figure>
<h1 class="block">Running a Markbench</h1>
<alert class="block" type="warning">These results were generated on 2024-05-16 with an AMD Ryzen 7950X3D paired with 6400Mhz RAM. It is possible that JavaScript engine developers address the shortcomings in the future and as such the results may not be comparable anymore.</alert>
<p class="block">Now that we have everything, we just need to run the test, and even you can do it in your Browsers' Developer Console. Just paste this in: <code>const { default: asc } = await import("./arrayShallowClone.mjs"); for (let e of [256, 2048, 16384, 131072, 1048576]){ console.log(e); asc(e); };</code>, hit enter/return and it should start measuring. For my own sanity, I limited myself to NodeJS V8, Firefox SpiderMonkey and Chromium V8 only.</p>
<details class="block" closed>
<summary>
<h2>256 Elements</h2>
</summary>
<figure class="block">
<table class="block" data-sortable="true">
<thead>
<tr>
<th width="16%">Test</th>
<th width="28%">NodeJS v20.12.2</th>
<th width="28%">Firefox v127.0b2</th>
<th width="28%">Edge 124.0.2478.97</th>
</tr>
</thead>
<tr id="spread" data-unit="ops/ms">
<td data-unit="">spread</td>
<td>3744.706<br/>±837.720</td>
<td>427.328<br/>±160.628</td>
<td>194.148<br/>±21.069</td>
</tr>
<tr id="spreadNew" data-unit="ops/ms">
<td data-unit="">spreadNew</td>
<td>772.173<br/>±4.565</td>
<td>253.318<br/>±14.201</td>
<td>944.612<br/>±65.431</td>
</tr>
<tr id="arraySlice" data-unit="ops/ms">
<td data-unit="">arraySlice</td>
<td>8153.649<br/>±74.025</td>
<td>11135.403<br/>±122.001</td>
<td>4332.440<br/>±566.803</td>
</tr>
<tr id="arraySlice0" data-unit="ops/ms">
<td data-unit="">arraySlice0</td>
<td>8097.358<br/>±221.166</td>
<td>10743.045<br/>±1498.073</td>
<td>4132.175<br/>±291.671</td>
</tr>
<tr id="arrayConcat" data-unit="ops/ms">
<td data-unit="">arrayConcat</td>
<td>7824.854<br/>±1304.302</td>
<td>8114.314<br/>±59.299</td>
<td>4266.308<br/>±519.280</td>
</tr>
<tr id="arrayMap" data-unit="ops/ms">
<td data-unit="">arrayMap</td>
<td>1556.550<br/>±555.325</td>
<td>1477.043<br/>±30.384</td>
<td>1601.246<br/>±496.596</td>
</tr>
<tr id="objectValues" data-unit="ops/ms">
<td data-unit="">objectValues</td>
<td>395.249<br/>±13.034</td>
<td>11097.390<br/>±115.324</td>
<td>2351.786<br/>±298.991</td>
</tr>
<tr id="objectAssign" data-unit="ops/ms">
<td data-unit="">objectAssign</td>
<td>24.374<br/>±0.333</td>
<td>10540.011<br/>±99.678</td>
<td>1742.264<br/>±198.06</td>
</tr>
<tr id="json" data-unit="ops/ms">
<td data-unit="">json</td>
<td>14.893<br/>±0.190</td>
<td>263.623<br/>±6.714</td>
<td>371.344<br/>±37.337</td>
</tr>
<tr id="loop" data-unit="ops/ms">
<td data-unit="">loop</td>
<td>558.856<br/>±33.762</td>
<td>452.884<br/>±134.844</td>
<td>1003.259<br/>±186.607</td>
</tr>
</table>
</figure>
</details>
<p class="block">For extremely small arrays, the best option appears to be <code>Array.slice()</code> across the board. To hopefully no surprise, the most commonly suggest solution are also among the worst ones: <code>[...array]</code>, and <code>JSON.parse(JSON.stringify(array))</code>. Interestingly, Firefox's SpiderMonkey appears to be cheating here a bit and treats several methods almost identically.</p>
<details class="block" closed>
<summary>
<h2>2048 Elements</h2>
</summary>
<figure class="block">
<table class="block" data-sortable="true">
<thead>
<tr>
<th width="16%">Test</th>
<th width="28%">NodeJS v20.12.2</th>
<th width="28%">Firefox v127.0b2</th>
<th width="28%">Edge 124.0.2478.97</th>
</tr>
</thead>
<tr id="spread" data-unit="ops/ms">
<td data-unit="">spread</td>
<td>564.068<br/>±69.269</td>
<td>54.662<br/>±12.295</td>
<td>24.842<br/>±4.052</td>
</tr>
<tr id="spreadNew" data-unit="ops/ms">
<td data-unit="">spreadNew</td>
<td>100.493<br/>±18.711</td>
<td>8.528<br/>±0.445</td>
<td>166.986<br/>±3.784</td>
</tr>
<tr id="arraySlice" data-unit="ops/ms">
<td data-unit="">arraySlice</td>
<td>1200.385<br/>±107.918</td>
<td>10952.114<br/>±327.757</td>
<td>2132.616<br/>±419.095</td>
</tr>
<tr id="arraySlice0" data-unit="ops/ms">
<td data-unit="">arraySlice0</td>
<td>1244.755<br/>±347.808</td>
<td>10291.675<br/>±718.242</td>
<td>2360.948<br/>±313.196</td>
</tr>
<tr id="arrayConcat" data-unit="ops/ms">
<td data-unit="">arrayConcat</td>
<td>1195.633<br/>±202.225</td>
<td>7922.923<br/>±152.032</td>
<td>2330.935<br/>±734.994</td>
</tr>
<tr id="arrayMap" data-unit="ops/ms">
<td data-unit="">arrayMap</td>
<td>230.627<br/>±11.868</td>
<td>203.621<br/>±10.492</td>
<td>380.155<br/>±8.593</td>
</tr>
<tr id="objectValues" data-unit="ops/ms">
<td data-unit="">objectValues</td>
<td>49.283<br/>±8.554</td>
<td>11062.764<br/>±580.898</td>
<td>668.146<br/>±24.536</td>
</tr>
<tr id="objectAssign" data-unit="ops/ms">
<td data-unit="">objectAssign</td>
<td>2.721<br/>±0.471</td>
<td>10545.962<br/>±159.850</td>
<td>567.441<br/>±7.771</td>
</tr>
<tr id="json" data-unit="ops/ms">
<td data-unit="">json</td>
<td>1.773<br/>±0.237</td>
<td>41.225<br/>±1.094</td>
<td>61.034<br/>±0.985</td>
</tr>
<tr id="loop" data-unit="ops/ms">
<td data-unit="">loop</td>
<td>73.709<br/>±13.677</td>
<td>63.957<br/>±10.470</td>
<td>152.831<br/>±47.908</td>
</tr>
</table>
</figure>
</details>
<p class="block">Not much changes in the more average use case, only cementing further that the Spread operator and JSON-clone are among the worst options. Similarly to before, Firefox's SpiderMonkey is still cheating a lot, and doesn't seem to do any actual cloning. I've been unable to make SpiderMonkey behave, so we'll just ignore Firefox from now on.</p>
<details class="block" closed>
<summary>
<h2>16384 Elements</h2>
</summary>
<figure class="block">
<table class="block" data-sortable="true">
<thead>
<tr>
<th width="16%">Test</th>
<th width="28%">NodeJS v20.12.2</th>
<th width="28%">Firefox v127.0b2</th>
<th width="28%">Edge 124.0.2478.97</th>
</tr>
</thead>
<tr id="spread" data-unit="ops/ms">
<td data-unit="">spread</td>
<td>13.180<br/>±1.022</td>
<td>7.436<br/>±1.110</td>
<td>3.321±0.239</td>
</tr>
<tr id="spreadNew" data-unit="ops/ms">
<td data-unit="">spreadNew</td>
<td>4.727<br/>±0.532</td>
<td>1.010<br/>±0.022</td>
<td>21.045±2.982</td>
</tr>
<tr id="arraySlice" data-unit="ops/ms">
<td data-unit="">arraySlice</td>
<td>12.912<br/>±2.127</td>
<td>11046.737<br/>±237.575</td>
<td>494.359±32.726</td>
</tr>
<tr id="arraySlice0" data-unit="ops/ms">
<td data-unit="">arraySlice0</td>
<td>13.192<br/>±0.477</td>
<td>10665.299<br/>±500.553</td>
<td>492.209±66.837</td>
</tr>
<tr id="arrayConcat" data-unit="ops/ms">
<td data-unit="">arrayConcat</td>
<td>16.590<br/>±0.656</td>
<td>7923.657<br/>±224.637</td>
<td>476.975±112.053</td>
</tr>
<tr id="arrayMap" data-unit="ops/ms">
<td data-unit="">arrayMap</td>
<td>6.542<br/>±0.301</td>
<td>32.960<br/>±3.743</td>
<td>52.127±3.472</td>
</tr>
<tr id="objectValues" data-unit="ops/ms">
<td data-unit="">objectValues</td>
<td>4.339<br/>±0.111</td>
<td>10840.392<br/>±619.567</td>
<td>115.369±3.217</td>
</tr>
<tr id="objectAssign" data-unit="ops/ms">
<td data-unit="">objectAssign</td>
<td>0.270<br/>±0.013</td>
<td>10471.860<br/>±202.291</td>
<td>83.135±3.439</td>
</tr>
<tr id="json" data-unit="ops/ms">
<td data-unit="">json</td>
<td>0.205<br/>±0.039</td>
<td>4.014<br/>±1.679</td>
<td>7.730±0.319</td>
</tr>
<tr id="loop" data-unit="ops/ms">
<td data-unit="">loop</td>
<td>6.138<br/>±0.287</td>
<td>6.727<br/>±1.296</td>
<td>27.691±1.217</td>
</tr>
</table>
</figure>
</details>
<p class="block">As size increases, cloning slows down significantly, and we start seeing oddities in some engines. With NodeJS, suddenly <code>[].concat(array)</code> has become significantly faster than <code>array.slice()</code> - almost 30% faster even! This suggests that there is a certain size threshold after which slicing an Array stops being fast in V8.</p>
<details class="block" closed>
<summary>
<h2>131072 Elements</h2>
</summary>
<figure class="block">
<table class="block" data-sortable="true">
<thead>
<tr>
<th width="16%">Test</th>
<th width="28%">NodeJS v20.12.2</th>
<th width="28%">Firefox v127.0b2</th>
<th width="28%">Edge 124.0.2478.97</th>
</tr>
</thead>
<tr id="spread" data-unit="ops/ms">
<td data-unit="">spread</td>
<td>2.152<br/>±0.062</td>
<td>0.787<br/>±0.029</td>
<td>0.305<br/>±0.040</td>
</tr>
<tr id="spreadNew" data-unit="ops/ms">
<td data-unit="">spreadNew</td>
<td>NaN<br/>±Infinity</td>
<td>0.122<br/>±0.005</td>
<td>NaN<br/>±Infinity</td>
</tr>
<tr id="arraySlice" data-unit="ops/ms">
<td data-unit="">arraySlice</td>
<td>2.255<br/>±0.092</td>
<td>10978.511<br/>±434.535</td>
<td>4.450<br/>±0.429</td>
</tr>
<tr id="arraySlice0" data-unit="ops/ms">
<td data-unit="">arraySlice0</td>
<td>2.251<br/>±0.076</td>
<td>10959.391<br/>±164.369</td>
<td>4.496<br/>±0.319</td>
</tr>
<tr id="arrayConcat" data-unit="ops/ms">
<td data-unit="">arrayConcat</td>
<td>2.249<br/>±0.347</td>
<td>7989.046<br/>±365.379</td>
<td>4.316<br/>±0.462</td>
</tr>
<tr id="arrayMap" data-unit="ops/ms">
<td data-unit="">arrayMap</td>
<td>1.034<br/>±0.057</td>
<td>4.006<br/>±0.543</td>
<td>2.988<br/>±0.841</td>
</tr>
<tr id="objectValues" data-unit="ops/ms">
<td data-unit="">objectValues</td>
<td>0.445<br/>±0.028</td>
<td>10516.475<br/>±918.310</td>
<td>3.843<br/>±0.389</td>
</tr>
<tr id="objectAssign" data-unit="ops/ms">
<td data-unit="">objectAssign</td>
<td>0.031<br/>±0.002</td>
<td>10339.829<br/>±749.871</td>
<td>10.521<br/>±1.427</td>
</tr>
<tr id="json" data-unit="ops/ms">
<td data-unit="">json</td>
<td>0.026<br/>±0.002</td>
<td>0.533<br/>±0.023</td>
<td>0.510<br/>±0.022</td>
</tr>
<tr id="loop" data-unit="ops/ms">
<td data-unit="">loop</td>
<td>0.499<br/>±0.026</td>
<td>0.811<br/>±0.022</td>
<td>1.104<br/>±0.036</td>
</tr>
</table>
</figure>
</details>
<p class="block">At this point you'd normally already be using a Database, and this very clearly shows in the numbers too. Everything has practically slowed down to a crawl, except for one thing: <code>Object.assign([], array)</code> in Edge. It is more than twice as fast as <code>array.slice()</code>, despite being almost 100% slower in the same V8 engine under NodeJS. We also return to <code>array.slice()</code> being the fastest way in NodeJS again - odd, but it is consistent even after multiple runs.</p>
<details class="block" closed>
<summary>
<h2>1048576 Elements</h2>
</summary>
<figure class="block">
<table class="block" data-sortable="true">
<thead>
<tr>
<th width="16%">Test</th>
<th width="28%">NodeJS v20.12.2</th>
<th width="28%">Firefox v127.0b2</th>
<th width="28%">Edge 124.0.2478.97</th>
</tr>
</thead>
<tr id="spread" data-unit="ops/ms">
<td data-unit="">spread</td>
<td>0.280<br/>±0.007</td>
<td>0.096<br/>±0.008</td>
<td>0.038<br/>±0.001</td>
</tr>
<tr id="spreadNew" data-unit="ops/ms">
<td data-unit="">spreadNew</td>
<td>NaN<br/>±Infinity</td>
<td>NaN<br/>±Infinity</td>
<td>NaN<br/>±Infinity</td>
</tr>
<tr id="arraySlice" data-unit="ops/ms">
<td data-unit="">arraySlice</td>
<td>0.371<br/>±0.006</td>
<td>10994.583<br/>±320.441</td>
<td>0.774<br/>±0.008</td>
</tr>
<tr id="arraySlice0" data-unit="ops/ms">
<td data-unit="">arraySlice0</td>
<td>0.362<br/>±0.008</td>
<td>10947.817<br/>±152.471</td>
<td>0.770<br/>±0.022</td>
</tr>
<tr id="arrayConcat" data-unit="ops/ms">
<td data-unit="">arrayConcat</td>
<td>0.367<br/>±0.044</td>
<td>7990.954<br/>±173.320</td>
<td>0.771<br/>±0.014</td>
</tr>
<tr id="arrayMap" data-unit="ops/ms">
<td data-unit="">arrayMap</td>
<td>0.018<br/>±0.002</td>
<td>0.536<br/>±0.008</td>
<td>0.283<br/>±0.002</td>
</tr>
<tr id="objectValues" data-unit="ops/ms">
<td data-unit="">objectValues</td>
<td>0.017<br/>±0.000</td>
<td>11160.335<br/>±413.641</td>
<td>0.584<br/>±0.008</td>
</tr>
<tr id="objectAssign" data-unit="ops/ms">
<td data-unit="">objectAssign</td>
<td>0.002<br/>±0.001</td>
<td>10549.829<br/>±167.641</td>
<td>1.362<br/>±0.028</td>
</tr>
<tr id="json" data-unit="ops/ms">
<td data-unit="">json</td>
<td>0.003<br/>±0.000</td>
<td>0.058<br/>±0.002</td>
<td>0.048<br/>±0.002</td>
</tr>
<tr id="loop" data-unit="ops/ms">
<td data-unit="">loop</td>
<td>0.068<br/>±0.002</td>
<td>0.092<br/>±0.004</td>
<td>0.072<br/>±0.041</td>
</tr>
</table>
</figure>
</details>
<p class="block">This is practically insanity, but hey - why not test it anyway. This is effectively a repeat of the previous block, but the numbers are even smaller. In NodeJS the fastest way is still <code>array.slice()</code>, in Edge it is <code>Object.assign([], array)</code>, and Firefox is still cheating.</p>
<h2 class="block">What is truly the fastest way to shallow clone an array?</h2>
<p class="block">Going by the results I've gotten on my machine, it appears that in modern JavaScript engines <code>Array.slice()</code> has become the fastest way overall. It's also supported even in the <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/slice#browser_compatibility" target="_blank">most ancient browsers</a>, so it's a clear winner in my book. I've already implemented it, and it's sped up processing time quite a lot.</p>