<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Abhijit Mondal on Medium]]></title>
        <description><![CDATA[Stories by Abhijit Mondal on Medium]]></description>
        <link>https://medium.com/@mecha-mind?source=rss-3d860626bbc7------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*-zaOwp3lXfv4ETm8F5fmpQ.jpeg</url>
            <title>Stories by Abhijit Mondal on Medium</title>
            <link>https://medium.com/@mecha-mind?source=rss-3d860626bbc7------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sat, 14 Nov 2026 22:17:08 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@mecha-mind/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com/" rel="hub"/>
        <item>
            <title><![CDATA[Distributed Matrix Multiplication using MPI on Azure]]></title>
            <link>https://medium.com/gaussian-machine/distributed-matrix-multiplication-using-mpi-on-azure-a7730be6c7ca?source=rss-3d860626bbc7------2</link>
            <guid isPermaLink="false">https://medium.com/p/a7730be6c7ca</guid>
            <category><![CDATA[matrix-multiplication]]></category>
            <category><![CDATA[mpi]]></category>
            <category><![CDATA[distributed-computing]]></category>
            <category><![CDATA[cpp]]></category>
            <category><![CDATA[azure]]></category>
            <dc:creator><![CDATA[Abhijit Mondal]]></dc:creator>
            <pubDate>Sat, 26 Oct 2024 09:31:51 GMT</pubDate>
            <atom:updated>2024-10-26T13:45:21.961Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*wQXTxyQWcSqpVib1SpAe-g.jpeg" /></figure><p>*Note — This post assumes that you are already familiar with OpenMPI and MPI syntax in C/C++.</p><p>There could be several reasons as to why one would perform matrix multiplication in a distributed manner.</p><ol><li>Size of input matrices are larger than available memory on a single node machine.</li><li>Although size of input matrices are small enough to fit in the available memory of a single node machine but the time complexity is O(n³) and for large n, this is prohibitively large.</li></ol><p>Matrix multiplication finds a wide range of applications in the ML field and is quite heavily used in different ML libraries and algorithms.</p><p>Thus, we see that we have a lot of motivation to understand how to build a distributed algorithm for doing matrix multiplication.</p><p>Before any implementation let us try to see how to write a distributed algorithm for matrix multiplication.</p><p>There is no single best algorithm as different algorithms tries to solve different constraints and thus works differently.</p><ol><li>Optimize for low memory on single node machines.</li><li>Optimize for the network bandwidth and topology of a cluster of nodes.</li><li>Optimize for faster run-time performance.</li></ol><p>You will notice that most of the work on the 1st and 2nd pointers i.e. optimize for low memory and network topology were done quite a few years back when memory sizes were comparatively very small to today’s machines and network bandwidths were also low.</p><p>Some well established algorithms in these space are:</p><ol><li>Cannon’s algorithm.</li><li>Fox’s algorithm.</li></ol><p>Both these algorithms assume that the nodes on which computations are run have low memory and the cluster of nodes are arranged in a grid (or rather a Torus) like topology.</p><p>Let’s take a look at how Fox’s algorithm works internally:</p><p>In a non-distributed setting, given matrices A and B of dimensions NxM and MxP respectively, we can calculate C=A.B in the following way:</p><p>The algorithm is run in multiple stages. For a given stage S:</p><p>For each i from 0 to N-1, multiply scalar A[i,(i+S) %M] with the row vector B[(i+S)%M,:]. This will give a partial values for the row :</p><p>C[i, :] += A[i, (i+S) %M] . B[(i+S)%M, :]</p><p>Repeat for all stages S from 0 to M-1. Pseudocode is below:</p><pre>Input:<br>A - NxM<br>B - MxP<br><br>Output:<br>C - NxP<br><br>For each stage j from [0, M)<br>  For each i from [0, N)<br>    Calculate C[i,:] += A[i,(i+stage)%N].B[(i+stage)%N,:]<br><br>return C</pre><p>In a distributed setting, lets assume that we have KxK nodes arranged in a grid topology. The concept of a grid topology implies that it is faster to connect to T[i+1,j] or T[i-1,j] or T[i,j+1] or T[i,j-1] from T[i,j] where T[i,j] refers to the node at index (i,j) in the grid.</p><p>Thus, each node at (i, j) is responsible for computing the values of the sub-matrix :</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*pHDnQ-WK7rvBjNXK-ExTYQ.png" /></figure><p>Initially the B matrix is distributed to the nodes s.t. node at grid index (i,j) i.e. T[i,j] gets the sub-matrix :</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/524/1*WaH-b3SxhAwsabtFJ-_8oA.png" /></figure><p>In stage S, the scalar values A[j, (j+S) % M] for j from Ui to Ui+N/K are broadcasted to all nodes in the i-th row of the grid :</p><p>i.e. T[i,0], T[i,1], … T[i,K-1]</p><p>After each stage, the B matrix is ‘shifted up’ across all the nodes in the grid i.e. node T[i,j] sends the values B[Ui, Vj:Vj+P/K] to the node T[(i-1)%K, j] and the process is repeated for all M stages.</p><p>Once all the nodes have finished all their stages, we can merge all the sub-matrices C from each node.</p><p>Let’ take an example to demonstrate this. Assume we have the following 4x4 matrices A and B.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/718/1*pL_tSc3P4evNNoB1-L4PsQ.png" /></figure><p>Also, assume that we have 4 nodes for distributed computing arranged in a 2x2 grid :</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/622/1*-f3R9ujC0geqkCwVKRvXOQ.png" /></figure><p>The matrix B is initially assigned to the nodes as follows:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/584/1*Gf6lulMn8HGSgxBP7DRE2g.png" /></figure><p>For stage 0, the values of A are assigned as follows to the nodes :</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/590/1*3FePXEyXlUa8RmQzHeQOwA.png" /></figure><p>In each node, we multiply the corresponding A and B values as shown in the above diagram and add them to output. For e.g. in T[0,0], we maintain the following:</p><p>C[0,0] += A[0,0]*B[0,0]</p><p>C[0,1] += A[0,0]*B[0,1]</p><p>C[1,0] += A[1,1]*B[1,0]</p><p>C[1,1] += A[1,1]*B[1,1]</p><p>In stage 1, values of A are shifted right by 1 column to the right and values of B are shifted by 1 row up. Both these are with wrap-around.</p><p>Note that the new values of B need not come from the master node as for a given node, the new row of B that it needs, is already present in the node located below it in the grid.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/585/1*T2JmptJ-8wxXhHqt7dCRrw.png" /></figure><p>For node T[0,0] we have:</p><p>C[0,0] += A[0,1]*B[1,0]</p><p>C[0,1] += A[0,1]*B[1,1]</p><p>C[1,0] += A[1,2]*B[2,0]</p><p>C[1,1] += A[1,2]*B[2,1]</p><p>Similarly for the other nodes too.</p><p>In stage 2, again we shift A values by 1 column to right and B by 1 row up with wrap-around.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/589/1*DTTr5xBmx7lPDnLhv8uTlQ.png" /></figure><p>Lastly for stage 3, we do the same :</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/586/1*bPsNhqczQyj1tfm1eIEKEA.png" /></figure><p>After all the stages, the products A[i,j]*B[j,k] in each cell are added up and the partial values of C[i,k] are calculated.</p><p>If you observe carefully, the A matrix is rotating in the horizontal direction whereas the B matrix in a vertical direction as if they are lying on a Torus.</p><p>The advantage of this algorithm is that each node is dealing with only O(N) memory when each matrix is of size NxN.</p><p>For a cluster with K nodes.</p><p>In each stage we are sending values of A to each node over the network and each node is sending a row of B to the node located just above itself. Thus, the number of network I/O is:</p><p>Total Network I/O = O(M.K), where M is the number of columns in A and is also equal to the number of stages.</p><p>Although we are using low memory footprint per node, but we are trading it off with higher number of network communications with smaller payloads.</p><p>Thus, this approach works well when we have low memory on each node and network bandwidths are low but its performance will be slow due to lots of network I/O.</p><p>Given today’s modern servers with high memory and high network bandwidths, a simpler distributed algorithm is as follows:</p><p>If we take the j-th column of A and j-th row of B, then their dot product will be a NxN matrix. If we sum these matrices for all j from 0 to M-1, then we will get the final matrix C.</p><p>We can distribute the columns j to j+M/K and rows j to j+M/K to a node. We are assuming that there are K nodes (without any topology). Then the node with rank=k, will compute the partial matrix C_k as follows :</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/668/1*2exuYF1mjpPjMkS2SzP68g.png" /></figure><p>This node will then send the partial matrices C_k to the master node with rank=0. The master node will then sum up all the individual elements of each partial matrix obtained from all nodes to get the resultant matrix C.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/240/1*b5uJDJ3uaXnX6Bj94G2-fQ.png" /></figure><pre>Input:<br>A - NxM<br>B - MxP<br>K = Num nodes<br><br>Output:<br>C - NxP<br><br>Let h = M/K<br><br>If rank == 0:<br>  Compute C = A[:, :M/K] . B[:M/K, :]<br>  For j in [M/K, M):<br>    Send A_sub = A[:, j:j+M/K] to node with rank=j/h<br>    Send B_sub = B[j:j+M/K, :] to node with rank=j/h<br><br>If rank != 0:<br>  Obtain A_sub and B_sub from node with rank=0<br>  Compute C = A_sub . B_sub<br>  Send C to node with rank=0<br><br>If rank = 0:<br>  For k in [0, K):<br>    Obtain C_k from node with rank=k<br>    C += C_k<br><br>return C</pre><p>If there are K nodes, then the number of network I/O is O(K), because we are sending columns from A and rows from B to a node only once.</p><p>Finally, each node sends back its matrix to the master node i.e. O(K) number of communications.</p><p>But in each node we require O(N²) memory as compared to O(N) in the Fox’s algorithm. Thus we are optimizing for less number of network I/O in this algorithm but higher memory requirements.</p><p>One drawback with this approach is that all the nodes will be sending their matrix of size NxP to the master node with rank=0. Thus, the master node will be doing O(K) additions of O(NxP) matrices.</p><p>Time complexity for final step is O(K.N.P).</p><p>Alternative approach to consolidate the matrices is to merge in stages. Assuming K is a power of 2, for e.g. if K=8, the nodes will be :</p><p>0 1 2 3 4 5 6 7</p><p>In the 1st round, each node at even positions, will send its matrix to the node left of it i.e. 1 to 0, 3 to 2 and so on. The nodes at even position will obtain these matrices and sum with its own matrix.</p><p>In the next stage, we only consider the nodes:</p><p>0 2 4 6</p><p>Because these have the most up-to-date data. Follow the same process as above i.e. 6 sends its matrix to 4, 2 sends its data to 0. Nodes 0 and 4 will add the obtained matrices with its own matrices.</p><p>In the last stage, we will have the following:</p><p>0 4</p><p>Now 4 sends its data to node 0. Note that now node 0 contains all the data from nodes 0 to 7.</p><p>In this approach any node will have at-most log(K) matrices in its buffer from any other node as compared to K matrices in the earlier version. Thus node 0 will add up log(K) matrices instead of K matrices. Time complexity for final addition is O(log(K).N.P).</p><p>The C++ code using MPI to run this algorithm is as follows:</p><pre>#include &lt;mpi.h&gt;<br>// include other header files as required<br><br>using namespace std;<br><br>// Function to generate a random normal matrix<br>void generate(double *inp, int n, int m){<br>    std::random_device rd;<br>    std::mt19937 engine(rd());<br>    std::normal_distribution&lt;double&gt; dist(0.0, 1.0);<br><br>    for (int i = 0; i &lt; n; i++) {<br>        for (int j = 0; j &lt; m; j++) {<br>            inp[i*m+j] = dist(engine);<br>        }<br>    }<br>}<br><br>// Function to transpose a matrix<br>double *transpose(const double *a, const int n, const int m) {<br>    double *b = new double[n*m];<br><br>    for (int i = 0; i &lt; n; i++) {<br>        for (int j = 0; j &lt; m; j++) {<br>            b[j*n+i] = a[i*m+j];<br>        }<br>    }<br><br>    return b;<br>}<br><br>// Function called by all nodes except master node<br>void dot_mpi(const int n, const int m, const int p, const int rank, const int size) {<br>    int h = m/size;<br><br>    double *a_r = new double[h*n];<br>    double *b_r = new double[h*p];<br>    double *merged = new double[h*(n+p)];<br><br>    // Receive columns of A and rows of B from master node<br>    MPI_Recv(merged, h*(n+p), MPI_DOUBLE, 0, rank, MPI_COMM_WORLD, MPI_STATUS_IGNORE);<br><br>    std::copy(merged, merged+h*n, a_r);<br>    std::copy(merged+h*n, merged+h*n+h*p, b_r);<br><br>    // Calculate own product<br>    double *out = new double[n*p];<br>    for (int i = 0; i &lt; n*p; i++) out[i] = 0.0;<br><br>    for (int i = 0; i &lt; h; i++) {<br>        for (int j = 0; j &lt; n; j++) {<br>            for (int k = 0; k &lt; p; k++) {<br>                out[j*p+k] += a_r[i*n+j]*b_r[i*p+k];<br>            }<br>        }<br>    }<br><br>    // Send the matrices in stages to the master node<br>    for (int stage = 1; stage &lt; size; stage *= 2) {<br>        if ((rank % stage == 0) &amp;&amp; (rank-stage &gt;= 0) &amp;&amp; ((rank - stage) % (stage*2) == 0)) {<br>            MPI_Send(out, n*p, MPI_DOUBLE, rank-stage, rank, MPI_COMM_WORLD);<br>        }<br>        else if ((rank % stage == 0) &amp;&amp; (rank+stage &lt; size) &amp;&amp; (rank % (stage*2) == 0)) {<br>            double *out_r = new double[n*p];<br>            MPI_Recv(out_r, n*p, MPI_DOUBLE, rank+stage, rank+stage, MPI_COMM_WORLD, MPI_STATUS_IGNORE);<br>            for (int j = 0; j &lt; n*p; j++) {<br>                out[j] += out_r[j];<br>            }<br>        }<br>    }<br>}<br><br>// Function called for master node<br>double *dot_mpi_root(const double *a, const double *b, const int n, const int m, const int p, const int size) {<br>    double *out = new double[n*p];<br>    for (int i = 0; i &lt; n*p; i++) out[i] = 0.0;<br><br>    // Instead of selecting columns of A, transpose A and select rows of A<br>    // This allows us to efficiently select elements of A to send.<br>    double *aT = transpose(a, n, m);<br>    int h = m/size;<br><br>    MPI_Request request = MPI_REQUEST_NULL;<br><br>    for (int i = h; i &lt; m; i += h) {<br>        double *merged = new double[h*(n+p)];<br>        std::copy(aT+i*n, aT+(i+h)*n, merged);<br>        std::copy(b+i*p, b+(i+h)*p, merged+h*n);<br><br>        // Merge columns of A and rows of B before sending to a node<br>        // Reduces the number of network I/O<br>        MPI_Isend(merged, h*(n+p), MPI_DOUBLE, i/h, i/h, MPI_COMM_WORLD, &amp;request);<br>    }<br><br>    // Do own contribution to the product<br>    for (int i1 = 0; i1 &lt; h; i1++) {<br>        for (int j = 0; j &lt; n; j++) {<br>            for (int k = 0; k &lt; p; k++) {<br>                out[j*p+k] += aT[i1*n+j]*b[i1*p+k];<br>            }<br>        }<br>    }<br><br>    // Receive the computed matrices from other nodes and sum them<br>    for (int stage = 1; stage &lt; size; stage *= 2) {<br>        double *out_r = new double[n*p];<br>        MPI_Recv(out_r, n*p, MPI_DOUBLE, stage, stage, MPI_COMM_WORLD, MPI_STATUS_IGNORE);<br>        for (int j = 0; j &lt; n*p; j++) {<br>            out[j] += out_r[j];<br>        }<br>    }<br><br>    return out;<br>}<br><br>double *dot(const double *a, const double *b, const int n, const int m, const int p) {<br>    double *out = new double[n*p];<br><br>    for (int i = 0; i &lt; n; i++) {<br>        for (int j = 0; j &lt; m; j++) {<br>            for (int k = 0; k &lt; p; k++) {<br>                out[i*p+k] += a[i*m+j]*b[j*p+k];<br>            }<br>        }<br>    }<br><br>    return out;<br>}<br><br>int main(int argc, char *argv[]) {<br>    int n = atoi(argv[1]);<br>    int m = atoi(argv[2]);<br>    int p = atoi(argv[3]);<br><br>    MPI_Init(&amp;argc, &amp;argv);<br><br>    int size;<br>    MPI_Comm_size(MPI_COMM_WORLD, &amp;size);<br><br>    int rank;<br>    MPI_Comm_rank(MPI_COMM_WORLD, &amp;rank);<br><br>    double *a, *b;<br><br>    if (rank == 0) {<br>        // Form the input matrices only in the master node, other nodes<br>        // will have smaller memory footprints<br>        a = new double[n*m];<br>        b = new double[m*p];<br><br>        generate(a, n, m);<br>        generate(b, m, p);<br>    }<br><br>    double *out;<br><br>    auto start = std::chrono::high_resolution_clock::now();<br>    if (rank == 0) out = dot_mpi_root(a, b, n, m, p, size);<br>    else dot_mpi(n, m, p, rank, size);<br>    auto stop = std::chrono::high_resolution_clock::now();<br>    auto duration = std::chrono::duration_cast&lt;std::chrono::nanoseconds&gt;(stop - start);<br><br>    if (rank == 0) {<br>        std::cout &lt;&lt; duration.count() &lt;&lt; std::endl;<br><br>        // Compare the duration for distributed computation with single node<br>        // computation.<br>        start = std::chrono::high_resolution_clock::now();<br>        double *out = dot(a, b, n, m, p);<br>        stop = std::chrono::high_resolution_clock::now();<br>        duration = std::chrono::duration_cast&lt;std::chrono::nanoseconds&gt;(stop - start);<br><br>        std::cout &lt;&lt; duration.count() &lt;&lt; std::endl;<br>    }<br><br>    MPI_Finalize();<br>    return 0;<br>}</pre><p>So far we have described how to write a distributed matrix multiplication algorithm using C++ and MPI. If you wish you can also run the above code on your local machine instead of cloud based server.</p><p>To compile the above program (using -O3 flag for optimized binary file):</p><pre>mpicxx -O3 -o matrix_multiplication matrix_multiplication.cpp</pre><p>Now in order to run and test your code on your local machine, run the following command. Here I am assuming that there are 4 cores on my machine.</p><pre>mpirun -n 4 ./matrix_multiplication 500 500 500</pre><p>The program utilizes all the 4 cores while running the code. It will not run if you specify more number of processes than the number of cores.</p><p>But why run on local machine when you can run on a cluster of nodes where it will be actually useful to run since in a local machine you are limited by the amount of memory available.</p><p>To setup a simple HPC cluster on Azure with 16 standard_d4s_v5 nodes, follow these steps (assume you have az cli installed and you are using Ubuntu 22.04):</p><p>The steps are slightly modified from the ones posted here: <a href="https://www.glennklockwood.com/cloud/mpi-cluster.html">Quick MPI Cluster Setup on Azure (glennklockwood.com)</a>.</p><p>Note that this is not the most favorable approach for running a HPC system on cloud but it will help you develop the intuition behind everything that is happening in behind.</p><p>Step 1:</p><p>Setup the cloud-init script. Create a file named cloud-init.txt in your home folder with the following content:</p><pre>#cloud-config<br>package_upgrade: true<br>packages:<br>  - clustershell<br>  - openmpi-bin<br>  - libopenmpi-dev<br>  - mpich</pre><ul><li>clustershell tool is used to run the same linux command on multiple nodes. This is a very handy tool as we will see.</li><li>mpich is required for using MPI with C++. For C, mpich is not required.</li></ul><p>Step 2:</p><p>Create a resource group in your subscription:</p><pre># if having multiple subscriptions<br>az account set --subscription &lt;subscription_name&gt;<br>az group create --name mpi_test_rg --location eastus</pre><p>Step 3:</p><p>Create a proximity placement group (ppg). Every VM we put in here will be within the same low-latency network bubble.</p><pre>az ppg create --name mpi_ppg \<br>              --resource-group mpi_test_rg \<br>              --intent-vm-sizes standard_d4s_v5</pre><p>Step 4:</p><p>Create 16 Ubuntu 22.04 nodes in this proximity placement group:</p><pre>az vm create --name mpicluster \<br>             --resource-group mpi_test_rg \<br>             --image Ubuntu2204 \<br>             --ppg mpi_ppg \<br>             --generate-ssh-keys \<br>             --size standard_d4s_v5 \<br>             --accelerated-networking true \<br>             --custom-data cloud-init.txt \<br>             --count 16</pre><ul><li>--generate-ssh-keys puts your local ssh key ~/.ssh/id_rsa.pub into the authorized_keys file in all the nodes.</li><li>--custom-data cloud-init.txt passes in our cloud-init.txt file to the provisioning process. This installs the libraries mentioned in the cloud-init.txt file on all nodes.</li></ul><p>Step 5:</p><p>Get the public and private IP addresses of your nodes:</p><pre>az vm list-ip-addresses --resource-group mpi_test_rg -o table</pre><p>Note down the public and private IP address of the node with the hostname mpicluster0. This is the master node.</p><p>Step 6:</p><p>Copy the private key from your local machine to the master node. Replace the IP with the public IP address of master node.</p><p>This is a highly insecure step but is required for you to connect to all nodes from your master node.</p><pre>scp ~/.ssh/id_rsa 20.25.28.201:.ssh/<br>ssh 20.25.28.201</pre><p>The next steps assume that we are running the commands on the master node i.e. mpicluster0</p><p>Step 7:</p><p>To get passwordless ssh up and working (so we can noninteractively run commands on other nodes through SSH), first add all the compute nodes’ host keys to your SSH known_hosts file.</p><p>The ssh-keyscan command connects to other hosts and retrieves its host keys, which we then store in ~/.ssh/known_hosts on our main mpicluster0 node.</p><pre>for i in {0..15};do ssh-keyscan mpicluster${i};done &gt; ~/.ssh/known_hosts</pre><p>Step 8:</p><p>clush command runs the same command on all nodes mentioned using -w. Here it copies the private key and known_hosts file on master node to all other nodes.</p><p>This allows any node to connect to any other node without IP addresses.</p><pre>clush -w mpicluster[0-15] -c ~/.ssh/id_rsa ~/.ssh/known_hosts</pre><p>Step 9:</p><p>Install NFS server and client on all the nodes. Although NFS server is required only on master node. This allows us to mount and share filesystem on master node with all other nodes.</p><pre>clush -w mpicluster[0-15] sudo apt -y install nfs-common nfs-kernel-server</pre><p>Step 10:</p><p>Create a folder on all nodes. This folder will mount another folder on the master node via NFS.</p><pre>clush -w mpicluster[0-15] sudo mkdir /scratch</pre><p>Step 11:</p><p>On the master node, create a folder /shared which will be mounted and shared by all other nodes. Also add the current user as owner of this folder.</p><pre>sudo mkdir /shared<br>sudo chown user:user /shared</pre><p>Step 12:</p><p>Update the /etc/exports file in the master node with the possible IP address range of all the other nodes sharing the folder.</p><p>Here we are using 10.0.0.0/16. But the actual CIDR notation will depend on the private IP addresses from Step 5.</p><pre>sudo bash -c &#39;echo &quot;/shared 10.0.0.0/16(rw,no_root_squash,no_subtree_check)&quot; &gt;&gt; /etc/exports&#39;<br>sudo exportfs -a</pre><p>Step 13:</p><p>Run mount command on all nodes to mount the /shared directory on master node.</p><p>The IP address 10.0.0.7 is the private IP address of the master node i.e. mpicluster0. Update this according to your output from Step 5.</p><pre>clush -w mpicluster[0-15] sudo mount -t nfs -o vers=3,rsize=1048576,wsize=1048576,nolock,proto=tcp,nconnect=8 10.0.0.7:/shared /scratch</pre><p>Step 14:</p><p>On the master node create a file named matrix_multiplication.cpp and copy the code above for distributed matrix multiplication.</p><p>Then compile the code as below. The compiled binary will be located inside the /shared folder so that all nodes will have access to the compiled binary for running the code.</p><pre>mpicxx -O3 -o /shared/matrix_multiplication matrix_multiplication.cpp</pre><p>Step 15:</p><p>Finally we run the compiled code on all our 16 nodes as below. Since we are using 16 nodes, we are choosing the matrix sizes multiples of 16. The matrices here are of sizes 1600x1600.</p><pre>mpirun --host mpicluster0,mpicluster1,mpicluster2,mpicluster3,mpicluster4,mpicluster5,mpicluster6,mpicluster7,mpicluster8,mpicluster9,mpicluster10,mpicluster11,mpicluster12,mpicluster13,mpicluster14,mpicluster15 --mca btl tcp,vader,self --wdir /scratch ./matrix_multiplication 1600 1600 1600</pre><p>In our code, we are also comparing the runtime of a non-distributed matrix multiplication on the master node with the distributed one.</p><p>For smaller matrix sizes, obviously the non-distributed algorithm works much faster as compared to the distributed one because in distributed setting we have lots of network I/O which degrades performance.</p><p>But for larger matrices such as 1600x1600, the non-distributed algorithm is 4 times slower than the distributed one. It takes around 1.11 seconds with 1600x1600 matrices in non-distributed while it takes only 0.3 seconds on the distributed cluster.</p><p>As we can see that the effect of network I/O is much less as compared to the time complexity of matrix multiplication as the sizes of matrices become larger.</p><p>If you are interested in the Fox’s algorithm implementation using MPI, the code is uploaded in github: <a href="https://github.com/funktor/hpc_mpi/blob/main/matrix_multiplication_fox.cpp">hpc_mpi/matrix_multiplication.cpp at main · funktor/hpc_mpi (github.com)</a></p><h4>References</h4><ol><li><a href="https://edoras.sdsu.edu/~mthomas/sp17.605/lectures/MPI-MatMatMult.pdf">COMP 605: Introduction to Parallel Computing Topic: MPI: Matrix-Matrix Multiplication (sdsu.edu)</a></li><li><a href="https://mpitutorial.com/">A Comprehensive MPI Tutorial Resource · MPI Tutorial</a></li><li><a href="https://miguehm.github.io/fox-docs/introduction.html">Introduction — Fox MPI in python (miguehm.github.io)</a></li><li><a href="https://www.cs.csi.cuny.edu/~gu/teaching/courses/csc76010/slides/Matrix%20Multiplication%20by%20Nur.pdf">Matrix Multiplication (cuny.edu)</a></li><li><a href="https://www.glennklockwood.com/cloud/mpi-cluster.html">Quick MPI Cluster Setup on Azure (glennklockwood.com)</a></li><li><a href="https://www.digitalocean.com/community/tutorials/how-to-set-up-an-nfs-mount-on-ubuntu-22-04">How To Set Up an NFS Mount on Ubuntu 22.04 | DigitalOcean</a></li><li><a href="https://htor.inf.ethz.ch/teaching/mpi_tutorials/ppopp13/2013-02-24-ppopp-mpi-advanced.pdf">PowerPoint Presentation (ethz.ch)</a></li><li><a href="https://researchcomputing.princeton.edu/sites/g/files/toruqf311/files/documents/MPI_tutorial_Fall_Break_2022.pdf">MPI_tutorial_Fall_Break_2022.pdf (princeton.edu)</a></li><li><a href="https://www.cs.cornell.edu/courses/cs5220/2020fa/lec/2020-10-06-mpi.html#/">MPI programming (cornell.edu)</a></li><li><a href="https://www.paulnorvig.com/guides/using-mpi-with-c.html">Using MPI with C++: a basic guide (2024) (paulnorvig.com)</a></li><li><a href="https://princetonuniversity.github.io/PUbootcamp/sessions/parallel-programming/Intro_PP_bootcamp_2018.pdf">PowerPoint Presentation (princetonuniversity.github.io)</a></li></ol><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=a7730be6c7ca" width="1" height="1" alt=""><hr><p><a href="https://medium.com/gaussian-machine/distributed-matrix-multiplication-using-mpi-on-azure-a7730be6c7ca">Distributed Matrix Multiplication using MPI on Azure</a> was originally published in <a href="https://medium.com/gaussian-machine">Gaussian Machine</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Implementing SVD algorithm in Rust]]></title>
            <link>https://medium.com/gaussian-machine/implementing-svd-algorithm-in-rust-ac1489eb7ca4?source=rss-3d860626bbc7------2</link>
            <guid isPermaLink="false">https://medium.com/p/ac1489eb7ca4</guid>
            <category><![CDATA[linear-algebra]]></category>
            <category><![CDATA[machine-learning]]></category>
            <category><![CDATA[svd]]></category>
            <category><![CDATA[mathematics]]></category>
            <category><![CDATA[rust]]></category>
            <dc:creator><![CDATA[Abhijit Mondal]]></dc:creator>
            <pubDate>Tue, 08 Oct 2024 19:07:47 GMT</pubDate>
            <atom:updated>2024-10-10T13:29:24.799Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*15CaAxJOq-GMWSzCtEtlfQ.png" /></figure><p>Singular Value Decomposition also popularly known as SVD is one of the fundamental algorithms used in ML for doing low rank decomposition of large matrices. Some notable applications of SVD are as follows:</p><ol><li>Doing <strong>dimensionality reduction</strong> of large and <strong>sparse matrices</strong> used in document search and retrieval systems such as search engines. SVD helps to reduce the time complexity for search.</li><li>In <strong>recommender systems</strong>, SVD can be used to find <strong>latent vectors</strong> for users and items. The latent vectors can then be used to find similar users and items.</li><li><strong>Image compression</strong> by reducing the rank of the original image matrix.</li><li>Finding <strong>pseudo-inverse</strong> of non-square matrices. This is useful for many regression problems where the number of features (or variables) may not be equal to the number of training instances.</li></ol><p>In this post I’m not going to deep dive into the applications, as I just want to focus of how to implement SVD algorithm from scratch. If you are interested in understanding more about the applications, check out my slides here:</p><p><a href="https://docs.google.com/presentation/d/1nb8HSMvd8gd3oyV001Ahi4LAnHBbx9r9xfllfRp7NoE/edit#slide=id.g11259069cf3_0_0">SVD_New — Google Slides</a></p><p>I am assuming that you are familiar with what SVD is and what are the different components of SVD. For refresher, a matrix A of dimensions NxM can be decomposed into 3 matrices U, S and V as follows:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/259/0*TDbz6KccWgPoMLJa" /></figure><p>U is NxN matrix, S is NxM matrix and V is MxM matrix.</p><p>Columns of matrix U are the eigenvectors of A.A^T and columns of matrix V are the eigenvectors of the matrix A^T.A</p><p>The matrix S is a diagonal matrix where the diagonal elements are the square root of the eigenvalues of the matrix A.A^T (or A^T.A).</p><p>Thus, we now have sufficient information to calculate the matrices U, S and V. We need to find the eigenvectors of the matrices A.A^T and A^T.A and the eigenvalues for any one of those matrices. That brings us to the question of how to find eigenvalues and eigenvectors of a matrix.</p><p>If you are a Pythonista, you might find them quite easily using the Numpy library as follows:</p><pre>import numpy as np<br><br>n, m = 100, 100<br>a = np.random.normal(0,1,(n,m))<br><br>eigenvalues, eigenvectors = np.linalg.eig(a)<br><br>print(eigenvalues)<br>print(eigenvectors)</pre><p>Note that the eigenvalues and eigenvectors may be complex numbers. This is because when we are finding the eigenvalues, we are actually solving the following equation:</p><p>det(A-yI) = 0</p><p>y are the eigenvalues of the matrix A. det() is the determinant of matrix. Thus, we will obtain a polynomial in y. Not all roots of y will be real numbers.</p><p>But note that if A is a symmetric matrix, then all the eigenvalues are real numbers and also the eigenvectors are all real. The matrices A.A^T and A^T.A are symmetric matrices and thus the eigenvalues and eigenvectors are real.</p><p><a href="https://math.stackexchange.com/questions/354115/prove-that-the-eigenvalues-of-a-real-symmetric-matrix-are-real">linear algebra — Prove that the eigenvalues of a real symmetric matrix are real — Mathematics Stack Exchange</a></p><p>Thus, it is pretty easy to calculate the matrices U and V using the above.</p><pre>n, m = 100, 100<br>a = np.random.normal(0,1,(n,m))<br><br>eigenvalues_u, u = np.linalg.eig(a @ a.T)<br>eigenvalues_v, v = np.linalg.eig(a.T @ a)<br><br>s = np.diag(np.sqrt(eigenvalues_u))<br><br>np.testing.assert_allclose(u @ s @ v.T, a)</pre><p>Did you notice anything after running the above code ? You will most likely get assertion error. But why ?</p><p>This is because of sign errors of the eigenvectors.</p><p>Note that if you revert the sign of an eigenvector, it will still be a valid eignvector of the matrix. This is because in the eigenvector equation:</p><p>(A-yI).x = 0</p><p>Multiplying both sides by -1 has no effect.</p><p>If we compute U and V independently, we will land into this trouble where multiplying U, S and V^T does not give back the original matrix A. To overcome this problem, we can compute V first and then compute U:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/565/1*kawcVbhh3bHEoVWG8_P2jQ.png" /></figure><p>S inverse is 1.0 over the original values of S for all non-zero diagonal elements, for zero diagonal elements, we can keep them as 0.</p><pre>n, m = 100, 100<br>a = np.random.normal(0,1,(n,m))<br><br>eigenvalues_v, v = np.linalg.eig(a.T @ a)<br>s = np.diag(np.sqrt(eigenvalues_v))<br>s_inv = np.where(s == 0, 0, 1.0/s)<br>u = a @ v @ s_inv<br><br>np.testing.assert_allclose(u @ s @ v.T, a)</pre><p>But we shall see that the above approach is not a stable approach when the values of matrix A.A^T or A^T.A are smaller than the machine precision. For e.g. in the above code, if we change the values of matrix A to be sampled from 0 to 1e-200, we will notice that the approach fails with assertion error.</p><pre>n, m = 100, 100<br>a = np.random.normal(0,1e-200,(n,m))<br><br>eigenvalues_v, v = np.linalg.eig(a.T @ a)<br>s = np.diag(np.sqrt(eigenvalues_v))<br>s_inv = np.where(s == 0, 0, 1.0/s)<br>u = a @ v @ s_inv<br><br>np.testing.assert_allclose(u @ s @ v.T, a)<br><br><br><br><br>AssertionError                            Traceback (most recent call last)<br>Cell In[87], line 9<br>      6 s_inv = np.where(s == 0, 0, 1.0/s)<br>      7 u = a @ v @ s_inv<br>----&gt; 9 np.testing.assert_allclose(u @ s @ v.T, a)<br><br>    [... skipping hidden 1 frame]<br><br>File C:\Program Files\WindowsApps\PythonSoftwareFoundation.Python.3.10_3.10.3056.0_x64__qbz5n2kfra8p0\lib\contextlib.py:79, in ContextDecorator.__call__.&lt;locals&gt;.inner(*args, **kwds)<br>     76 @wraps(func)<br>     77 def inner(*args, **kwds):<br>     78     with self._recreate_cm():<br>---&gt; 79         return func(*args, **kwds)<br><br>File ~\AppData\Local\Packages\PythonSoftwareFoundation.Python.3.10_qbz5n2kfra8p0\LocalCache\local-packages\Python310\site-packages\numpy\testing\_private\utils.py:862, in assert_array_compare(comparison, x, y, err_msg, verbose, header, precision, equal_nan, equal_inf, strict)<br>    858         err_msg += &#39;\n&#39; + &#39;\n&#39;.join(remarks)<br>    859         msg = build_err_msg([ox, oy], err_msg,<br>    860                             verbose=verbose, header=header,<br>    861                             names=(&#39;x&#39;, &#39;y&#39;), precision=precision)<br>--&gt; 862         raise AssertionError(msg)<br>    863 except ValueError:<br>    864     import traceback<br><br>AssertionError: <br>Not equal to tolerance rtol=1e-07, atol=0<br><br>Mismatched elements: 25 / 25 (100%)<br>Max absolute difference: 1.90398337e-200<br>Max relative difference: 1.<br> x: array([[0., 0., 0., 0., 0.],<br>       [0., 0., 0., 0., 0.],<br>       [0., 0., 0., 0., 0.],...<br> y: array([[-1.834095e-200, -2.743976e-201, -1.228526e-200,  1.444880e-200,<br>         1.197758e-200],<br>       [-5.629092e-202, -6.866588e-201, -1.684314e-201,  8.075045e-201,...</pre><p>Instead, we will look into one of the earliest stable algorithm for SVD calculations known as the Golub-Kahan-Reisch SVD algorithm.</p><p>In the rest of this post we will understand this algorithm and implement it using Rust. Note that this algorithm is not the fastest out there and many algorithms such as the divide and conquer approach are used in standard packages.</p><p>The idea behind this algorithm is to multiply the matrix A by some orthogonal matrices L1, L2, … Lk on the left and some orthogonal matrices R1, R2, ..Rk on the right to form a diagonal matrix say X, i.e.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/805/1*bfucH5t0i7vUtc3i6wi-UQ.png" /></figure><p>Then we can write A as follows:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/634/1*DFKmvDWLegtcZMt0_9LpgQ.png" /></figure><p>The above follows from the fact that the matrices L and R are orthogonal i.e. L.L^T = I and L^T.L=I and similarly for R.</p><p>If we denote the following:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/332/1*FYYbrCxxXYMNHLvC4LxHVQ.png" /></figure><p>and</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/327/1*w8-0AtHdOjEHzmtYISgCTg.png" /></figure><p>Then we can rewrite A as follows:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/262/1*lsFCcC7K2pdz8Bdqcf5zPA.png" /></figure><p>This form looks familiar and it is infact the SVD of matrix A because X is a diagonal matrix and U and V are orthogonal. Now the only question remaining is how to find these orthogonal matrices L and R ?</p><p>Let’s deviate a bit from here into another related algorithm.</p><p>Remember QR decomposition from linear algebra class ?</p><p>QR decomposition of a NxM matrix A, produces 2 matrices Q and R where Q is a NxN orthogonal matrix and R is a NxM upper diagonal matrix.</p><p>The dimensionality of Q and R can be varied depending on the use case. For e.g. if M &lt; N, we can instead use NxM matrix for Q (first M columns) and MxM matrix for R (top M rows).</p><p>Note that this will not change the results because is N &gt; M and R is an upper diagonal matrix, then it is filled with all 0s in the lower (N-M)xM matrix which does not add any value.</p><p>There are 2 very popular algorithms to perform QR decomposition:</p><ol><li>Householder reflection</li><li>Givens’ rotation</li></ol><p>In Householder reflection method, firstly for column 0, all values in rows 1 to n-1 (0-indexed) are zeroed out, then for column 1, rows 2 to n-1 are zeroed out and so on, till we have zeroed out all sub-diagonal elements.</p><p>The algorithm is as follows:</p><pre>Let G = A - NxM matrix<br>Let H = I - NxN identity matrix<br><br>For each column i from 0 to min(N-1, M-1), repeat steps 1 to 6:<br>  1. Let x = G[i:,i]<br>  2. Let l2 = L2_norm(x)<br>  3. Update x[0] = x[0]-sgn(x[0])*l2<br>  4. Let u = x/L2_norm(x)<br>  5. Update G[i:,i:] = G[i:,i:] - 2*(u @ (u.T @ G[i:,i:]))<br>  6. Update H[i:,:] = H[i:,:]- 2*(u @ (u.T @ H[i:,:]))<br><br>Let K = min(N, M)<br>return Q=H.T[:,:K], R=G[:K,:]</pre><p>Time complexity of the above algorithm is O(NM²) ignoring the constant terms and lower order terms. Thus, if M &gt; N, then it is beneficial to perform QR decomposition on the transpose of A.</p><p>More elaborate explanation can be found in these slides:</p><p><a href="https://www.math.chalmers.se/~larisa/www/NumLinAlg/Lecture9_2019.pdf">Applied Numerical Linear Algebra. Lecture 9 (chalmers.se)</a></p><p>It can be shown that once a column has been zeroed out below its main diagonal, the algorithm for subsequent columns do not affect the already zeroed out columns.</p><p>Implementing the above algorithm using Rust:</p><pre>// returns sign of a real number<br>fn sgn(x:f64) -&gt; f64 {<br>    if x &lt; 0.0 {<br>        return -1.0;<br>    }<br>    return 1.0;<br>}<br><br>// generates an nxn identity matrix<br>fn identity(n:usize) -&gt; Vec&lt;f64&gt; {<br>    let mut q = vec![0.0;n*n];<br>    for i in 0..n {<br>        q[i*(n+1)] = 1.0;<br>    }<br><br>    return q;<br>}<br><br>// transpose a matrix<br>fn transpose(a:&amp;[f64], n:usize, m:usize) -&gt; Vec&lt;f64&gt; {<br>    let mut b = vec![0.0;n*m];<br><br>    for i in 0..n {<br>        for j in 0..m {<br>            b[j*n+i] = a[i*m+j];<br>        }<br>    }<br><br>    return b;<br>}<br><br>// householder reflection algorithm<br>fn householder_reflection_left_multiply(a:&amp;[f64], n:usize, m:usize) -&gt; (Vec&lt;f64&gt;, Vec&lt;f64&gt;) {<br>    let mut q_lt = identity(n);<br>    let mut r = a.to_vec();<br>    let w = min(n, m);<br><br>    for i in 0..w {<br>        let n1 = n-i;<br><br>        let mut nm = 0.0;<br>        let mut u = vec![0.0;n1];<br><br>        for i1 in i..n {<br>            // compute the L2 norm<br>            nm += r[i1*m+i]*r[i1*m+i];<br>            u[i1-i] = r[i1*m+i];<br>        }<br><br>        u[0] -= sgn(r[i*m+i])*nm.sqrt();<br>        nm = (nm-r[i*m+i]*r[i*m+i]+u[0]*u[0]).sqrt();<br><br>        if z &gt; 0.0 {<br>            for i1 in 0..n1 {<br>                u[i1] = u[i1]/nm;<br>            }<br><br>            // r1 stores the values of u.T @ R for each column<br>            let mut r1 = vec![0.0;m-i];<br>            for i1 in i..n {<br>                for j1 in i..m {<br>                    r1[j1-i] += u[i1-i]*r[i1*m+j1];<br>                }<br>            }<br><br>            // update the matrix R<br>            for i1 in i..n {<br>                for j1 in i..m {<br>                    r[i1*m+j1] -= 2.0*u[i1-i]*r1[j1-i];<br>                }<br>            }<br><br>            // q1 stores the values of u.T @ Q for each column<br>            let mut q1 = vec![0.0;n];<br>            for i1 in i..n {<br>                for j1 in 0..n {<br>                    q1[j1] += u[i1-i]*q_lt[i1*n+j1];<br>                }<br>            }<br><br>            // update the matrix Q<br>            for i1 in i..n {<br>                for j1 in 0..n {<br>                    q_lt[i1*n+j1] -= 2.0*u[i1-i]*q1[j1];<br>                }<br>            }<br>        }<br>    }<br><br>    return (q_lt, r);<br>}<br><br>pub fn householder_reflection_qr(a:&amp;[f64], n:usize, m:usize) -&gt; (Vec&lt;f64&gt;, Vec&lt;f64&gt;, usize, usize, usize) {<br>    // if n &lt; m, transpose and solve qr<br>    if n &lt; m {<br>        let aT = transpose(&amp;a, n, m);<br>        let (q, r) = householder_reflection_left_multiply(&amp;aT, m, n);<br>        return (transpose(&amp;r, m, n), q);<br>    }<br>    else {<br>        let (q, r) = householder_reflection_left_multiply(&amp;a, n, m);<br>        return (transpose(&amp;q, n, n), r);<br>    }<br>}<br><br>fn main() {<br>    let n = 500;<br>    let m = 500;<br><br>    //generate matrix of nxm shape from normal distribution<br>    let mut rng = thread_rng();<br>    let normal:Normal&lt;f64&gt; = Normal::new(0.0, 1.0).ok().unwrap();<br>    let mut a:Vec&lt;f64&gt; = vec![0.0;n*m];<br><br>    for i in 0..n*m {<br>        a[i] = normal.sample(&amp;mut rng);<br>    }<br><br>    let (q, r) = householder_reflection_qr(&amp;a, n, m);<br>}</pre><p>In Givens Rotation, instead of targetting entire column at a time, individual cell values at (i, j) are targetted. For e.g. if we have to zero out A[i,j], then we can left multiply the 2xM matrix A[i-1:i+1, :] by a 2x2 matrix H, where:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/444/1*NYUwKcaMSp2fwQOntRpUIg.png" /></figure><p>H.A[i-1:i+1, :] only affects rows i-1 and i. Thus, we have to zero out the values in a column in bottom up fashion. The algorithm is as follows:</p><pre>Let G = A - NxM matrix<br>Let H = I - NxN identity matrix<br><br>For each column j from 0 to M-1:<br>  For each row i from N-1 to i+1:<br>    1. Let r = sqrt(G[i-1,j]**2 + G[i,j]**2)<br>    2. Let c = G[i-1,j]/r<br>    3. Let s = -G[i,j]/r<br>    4. Let Q = [[c, -s], [s, c]]<br>    5. Update G[i-1:i+1, j:] = Q @ G[i-1:i+1, j:]<br>    6. Update H[i-1:i+1, j:] = Q @ H[i-1:i+1, j:]<br><br>Let K = min(N, M)<br>return Q=H.T[:,:K], R=G[:K,:]</pre><p>The time complexity of the above algorithm is O(NM²) which is similar to the Householder reflection method.</p><p>One advantage of Givens Rotation over Householder Reflection method is that in Givens Rotation, some cells can be zeroed out in parallel.</p><p>Since the Givens Rotation for (i, j) affects only rows i-1 and i, thus once we have zeroed out A[n-1,0] and A[n-2,0], then we can start to zero out columns 0 and 1 in parallel because column 0 will be working from row n-3 upwards and column 1 from row n-1 upwards.</p><p>Similar logic applies for all subsequent columns too.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*-arkMaT5-q5IBRgDCUnukg.png" /><figcaption>Slide from <a href="https://courses.grainger.illinois.edu/cs554/fa2015/notes/11_qr.pdf">Parallel Numerical Algorithms — Chapter 11 — QR Factorization (illinois.edu)</a></figcaption></figure><p>In the above diagram, cells with same values can be zeroed in parallel. For e.g. cells (3,0), (5,1) and (7,2) all having seq. num 5 can be run in parallel.</p><p>Here is an implementation for Givens Rotation using Rust (without the parallel implementation):</p><pre>fn givens_left_rotation(a:&amp;[f64], _n:usize, m:usize, i:usize, j:usize) -&gt; (f64, f64) {<br>    let x = a[(i-1)*m+j];<br>    let y = a[i*m+j];<br>    let r = (x*x + y*y).sqrt();<br><br>    return (x/r, -y/r);<br>}<br><br>fn givens_left_rotation_multiply(a:&amp;mut [f64], _n:usize, m:usize, c:f64, s:f64, i:usize, _j:usize) {<br>    for j1 in 0..m {<br>        let p = a[(i-1)*m+j1];<br>        let q = a[i*m+j1];<br>        a[(i-1)*m+j1] = c*p - s*q;<br>        a[i*m+j1] = s*p + c*q;<br>    }<br>}<br><br>pub fn givens_rotation_qr(a:&amp;[f64], n:usize, m:usize,) -&gt; (Vec&lt;f64&gt;, Vec&lt;f64&gt;, usize, usize, usize) {<br>    // if m &gt; sqrt(n), transpose and solve qr<br>    if n &lt; m {<br>        let mut r = transpose(&amp;a, n, m);<br>        let mut q = identity(m);<br><br>        for j in 0..n {<br>            for i in (j+1..m).rev() {<br>                let b = givens_left_rotation(&amp;r, m, n, i, j);<br>                givens_left_rotation_multiply(&amp;mut r, m, n, b.0, b.1, i, j);<br>                givens_left_rotation_multiply(&amp;mut q, m, m, b.0, b.1, i, j);<br>            }<br>        }<br><br>        return (transpose(&amp;r, m, n), q);<br>    }<br>    else {<br>        let mut r = a.to_vec();<br>        let mut q = identity(n);<br><br>        for j in 0..m {<br>            for i in (j+1..n).rev() {<br>                let b = givens_left_rotation(&amp;r, n, m, i, j);<br>                givens_left_rotation_multiply(&amp;mut r, n, m, b.0, b.1, i, j);<br>                givens_left_rotation_multiply(&amp;mut q, n, n, b.0, b.1, i, j);<br>            }<br>        }<br><br>        return (transpose(&amp;q, n, n), r);<br>    }<br>}</pre><p>The best part is that both Householder reflectors matrices and Givens Rotation matrices are orthogonal matrices.</p><p>Now, coming back to our SVD algorithm.</p><p>The first step of the algorithm is the ‘bidiagonalization’ of the input matrix A. The main diagonal and upper diagonal have non-zero values. The following matrix is a “upper-bidiagonal” matrix.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/435/1*D3l4C08O0vmbCFVQJTExBg.png" /></figure><p>Bidiagonalization has quite a few advantages:</p><ol><li>It is “close” in shape to a diagonal matrix, which we ultimately want.</li><li>The matrix is very sparse and we can efficiently perform different operations such as matrix multiplications quite cheaply.</li><li>If we denote the bidiagonal matrix with B, then the matrix B^T.B has a tridiagonal form, which is again sparse. We would be interested in finding the eigenvalues and eigenvectors of B^T.B.</li></ol><p>Bidiagonalization of a matrix can be done by using similar methods as in QR decomposition. Assuming that we choose the Householder reflection method, then first, we zero out all the elements below the main diagonal similar to the QR step seen earlier.</p><p>Then, we zero out all elements above the main diagonal by performing the Householder reflection on each row instead of each column. Note that it is not possible to zero out the diagonal above the main diagonal as then it will change the already zeroed out elements below the main diagonal.</p><p>Bidiagonalization using Rust:</p><pre>fn householder_reflection_bidiagonalization(a:&amp;[f64], n:usize, m:usize) -&gt; (Vec&lt;f64&gt;, Vec&lt;f64&gt;, Vec&lt;f64&gt;) {<br>    let mut q_lt = identity(n);<br>    let mut q_rt = identity(m);<br><br>    let mut r = a.to_vec();<br><br>    for i in 0..min(n, m) {<br>        let n1 = n-i;<br><br>        let mut nm = 0.0;<br>        let mut u = vec![0.0;n1];<br><br>        for i1 in i..n {<br>            nm += r[i1*m+i]*r[i1*m+i];<br>            u[i1-i] = r[i1*m+i];<br>        }<br><br>        u[0] -= sgn(r[i*m+i])*nm.sqrt();<br>        let z = (nm-r[i*m+i]*r[i*m+i]+u[0]*u[0]).sqrt();<br><br>        if z &gt; 0.0 {<br>            for i1 in 0..n1 {<br>                u[i1] = u[i1]/z;<br>            }<br><br>            let mut r1 = vec![0.0;m-i];<br>            for i1 in i..n {<br>                for j1 in i..m {<br>                    r1[j1-i] += u[i1-i]*r[i1*m+j1];<br>                }<br>            }<br><br>            for i1 in i..n {<br>                for j1 in i..m {<br>                    r[i1*m+j1] -= 2.0*u[i1-i]*r1[j1-i];<br>                }<br>            }<br><br>            let mut q1 = vec![0.0;n];<br>            for i1 in i..n {<br>                for j1 in 0..n {<br>                    q1[j1] += u[i1-i]*q_lt[i1*n+j1];<br>                }<br>            }<br><br>            for i1 in i..n {<br>                for j1 in 0..n {<br>                    q_lt[i1*n+j1] -= 2.0*u[i1-i]*q1[j1];<br>                }<br>            }<br>        }<br><br>        if m-i-1 &gt; 0 {<br>            let n1 = m-i-1;<br><br>            let mut nm = 0.0;<br>            let mut u = vec![0.0;n1];<br><br>            for j1 in i+1..m {<br>                nm += r[i*m+j1]*r[i*m+j1];<br>                u[j1-i-1] = r[i*m+j1];<br>            }<br><br>            u[0] -= sgn(r[i*m+i+1])*nm.sqrt();<br>            let z = (nm-r[i*m+i+1]*r[i*m+i+1]+u[0]*u[0]).sqrt();<br><br>            if z &gt; 0.0 {<br>                for i1 in 0..n1 {<br>                    u[i1] = u[i1]/z;<br>                }<br>                <br>                let mut r1 = vec![0.0;n-i];<br>                for i1 in i..n {<br>                    for j1 in i+1..m {<br>                        r1[i1-i] += u[j1-i-1]*r[i1*m+j1];<br>                    }<br>                }<br><br>                for i1 in i..n {<br>                    for j1 in i+1..m {<br>                        r[i1*m+j1] -= 2.0*u[j1-i-1]*r1[i1-i];<br>                    }<br>                }<br>                <br>                let mut q1 = vec![0.0;m];<br>                for i1 in 0..m {<br>                    for j1 in i+1..m {<br>                        q1[i1] += u[j1-i-1]*q_rt[i1*m+j1];<br>                    }<br>                }<br><br>                for i1 in 0..m {<br>                    for j1 in i+1..m {<br>                        q_rt[i1*m+j1] -= 2.0*u[j1-i-1]*q1[i1];<br>                    }<br>                }<br>            }<br>        }<br>    }<br><br>    return (q_lt, r, q_rt);<br>}</pre><p>Thus, after bidiagonalization step, we will obtain a bidiagonal matrix B:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/810/1*LY0y19U9RYb80XRWfzKVeA.png" /></figure><p>But note that, we still have not diagonalized the matrix A and have only bidiagonalized it. The matrices Q_L and Q_R are all orthogonal and so are their products.</p><p>Thus, finding SVD of A is reduced to finding SVD of B. If the SVD of B is represented as follows:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/310/1*N8HqpEdrL3m30H6qMTiGew.png" /></figure><p>Then, the SVD of A can be written as:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/964/1*ok-NFjNKO2qxbTUoG92adA.png" /></figure><p>The next natural step, would be to find the SVD of B, which again we can do so by finding the eigenvalues and eigenvectors of B^T.B as seen above.</p><p>But note that we were motivated to use this method for SVD calculation in the first place, was because calculations of A^T.A or B^T.B can have very small values smaller than the machine precision, thus giving unstable results when the eigenvalues are small.</p><p>Thus, we should avoid going the route of calculating B^T.B. Then, what are the alternative ways to diagonalize the matrix B ? The idea is to use B^T.B without explicitly forming the matrix B^T.B.</p><p>Let T = B^T.B</p><p>If B is a bidiagonal matrix, then T is a tridiagonal symmetric matrix as follows:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/717/1*MeaKHHKgEdszPrKmvTeJkA.png" /></figure><p>Assuming there are Givens Rotation matrices G to be multiplied to the right of T and G^T to the left of T, that will eventually zero out the non-diagonal elements of T i.e.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/656/1*QWBfa0FnkUkSgX-Ya3Bf6g.png" /></figure><p>Note that in QR decomposition once we zero out a cell (i, j) using Givens Left Rotation, we do not have to be bothered about the other columns that are affected in rows i-1 and i. But here, it is not so straightforward.</p><p>Remember that a Givens Left Rotation matrix for zeroing out (i, j) will affect only rows i-1 and i, whereas a Givens Right Rotation matrix will affect only columns j-1 and j.</p><p>Thus, if we zero out (0, 1) in T using G1 (Right Rotation), then it will affect only the columns 0 and 1 and (2,0) will be non-zero. Rest all zeros in columns 0 and 1 will be unaffected (if both columns have 0 in same row, both 0s are unaffected). Thus when we are zeroing out one cell, we are introducing a non-zero in another cell.</p><p>Similarly, if we apply G1^T to T.G1, then it is application of Givens Left Rotation and will affect rows 0 and 1. Thus, it will introduce a new non-zero values at (0,1) and (0,2). Columns where both rows 0 and 1 are 0 are unaffected.</p><p>Note, that application of G1^T to T.G1, will not zero out (1,0) because (1,0) value was changed after T.G1.</p><p>Repeat the process to zero out (0,2) and (2,0) by multiplying with G2 (right rotation) and G2^T (left rotation). This will introduce new non-zero values at (1,3) and (3,1).</p><p>Continue to repeat this process, until the new non-zero values are all chased out from T and we again obtain the tridiagonal form.</p><p>This entire process of “chasing out” a new non-zero element from T and restoring the tridiagonal form of T using Givens Left and Right rotation matrices is known as the Golub Kahan step.</p><p>In the below figure, the ‘x’ symbolizes a non-zero value. See how the 2 additional ‘x’’s are chased out to regain the original tridiagonal form.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*hfgBkbafQa45ZT3EUAw5ng.png" /></figure><p>After doing one round of Golub Kahan step as described above, the off-diagonal elements inches closer to 0 but may not yet be zero. After repeated application of the above steps, some of the off-diagonal elements starts to be closer to 0. We can assume that whenever the absolute value of an off-diagonal element is smaller than some threshold, consider it as 0.</p><p>To fasten the process of convergence to 0, Wilkinson Shift strategy is applied, which is basically using (T-uI) inplace of original T, where u is the eigenvalue of the bottom right 2x2 sub-matrix of T, that is closer to the last diagonal element.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/737/1*tNOUpz1pT4drsDF37A8XGQ.png" /></figure><p>Using (T-uI) inplace of T in the above steps will improve convergence rate for the off-diagonal elements going to 0.</p><p>But so far we have been doing left and right multiplications with Givens matrices on T but remember that we do not want to calculate T as because this will lead to error in calculations when the eigenvalues of T are smaller than the machine precision.</p><p>In the equation:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/640/1*K6q3j7-VyB5IrLdsV3nZ2Q.png" /></figure><p>If we replace T with B^T.B, we get:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/715/1*A7Wxnhd6DobgcDur-vzKyA.png" /></figure><p>Thus, instead of applying G1, G2, … Gk to T, we can apply them directly to B. But note that applying only the right rotation matrices G1, G2, … to B will introduce new non-zero elements in B in the lower diagonal.</p><p>These new non-zero elements introduced, can be chased out by Givens Left Rotation matrices.</p><p>For e.g. let there be Givens Left rotation matrices Q1, Q2… s.t.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*k8Y1k7uVhrLSpOdxtJg4lg.png" /></figure><p>Thus, left multiplying by matrices Q to keep the bidiagonal matrix B in the bidiagonal form does not alter the product as Q are orthogonal matrices.</p><p>Similar to the tridiagonal matrix, where multiplying left and right by Givens matrices chased away the new non-zero elements, we can see the similar thing happening with B also:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*PCbdHgf4Tr_WKcTA_PtJCg.png" /></figure><p>Note that, the above steps of multiplying B on the left and right by Givens rotation matrices to gradually remove the off-diagonal elements as well as chase away unwanted non-zero elements introduced during the process is applicable to only a bidiagonal matrix form.</p><p>Once some off-diagonal elements or some diagonal elements become 0, we have to divide the current matrix s.t. each part is bidiagonal and separately process each bidiagonal matrix.</p><p>For e.g. if a non-diagonal element say B[i,i+1] becomes 0 after sometime, then we separately process B[:i+1,:i+1] and B[i+1:,i+1:].</p><p>In the below example i=2.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1009/1*gLfEAzQ6NxRDvwle0SLA_A.png" /></figure><p>In the event that a diagonal element becomes 0 (implying at-least one eigenvalue of T is 0), then we can handle this situation as below:</p><p>For e.g. if B[i,i] = 0 and B[i,i+1] != 0, then applying Givens left rotation on rows i and i+1 to B to zero out B[i,i+1], will introduce a new non-zero at B[i,i+2]. Again applying Givens left rotation on rows i and i+1 to zero out B[i,i+2] will introduce new non-zero at B[i,i+3] and so on till B[i,M-1].</p><p>After that, the final Givens Rotation matrix will chase out the unwanted non-zero value from the matrix.</p><p>In the below example B[2,2] = 0</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/770/1*Wt6YUZupbmQXt7baru9ZWg.png" /></figure><p>Once we obtain multiple bidiagonal sub-matrices, we can process the Golub Kahan steps in parallel for each. The above Golub Kahan steps works fine even if the last diagonal of a bidiagonal matrix is 0.</p><p>Since we are multiplying the bidiagonal matrix left and right with Givens matrices after doing shift by the eigenvalue of the bottom right 2x2 sub-matrix closer to the last diagonal, the last super diagonal i.e. B[N-2,N-1] will be the first to go to zero. Then, after we process the remaining (N-2)x(N-2) matrix, the next off diagonal to go to zero would be B[N-3,N-2] and so on.</p><p>Thus, the off diagonal elements in B starts to go to zero starting from bottom right. Starting from i=N-2, for each i, we can check if B[i,i+1]= 0, if it is 0 then continue going to the top by decreasing i else stop and process the sub-matrix B[:i+1,:i+1].</p><p>Return after the matrix B have become a diagonal matrix.</p><p>The algorithm is as follows :</p><p>Instead of processing multiple bidiagonal matrices after splits in parallel, here we are processing them in bottom up fashion. We can use multithreading to process them in parallel but would not expect lot of performance gains as these are CPU bound calculations.</p><pre>Let A - NxM matrix<br><br>1. B = P @ A @ Q where B is bidiagonal matrix (Householder Reflection)<br>2. Loop 3-8<br>  3. Set B[i,i+1] = 0 if abs(B[i,i+1]) &lt; eps*(abs(B[i,i) + abs(B[i+1,i+1))<br>  4. Find q s.t. B[i,i+1]=0 for all i in (q+1, r-1), if not found, set q = 0<br>  5. Break if q = 0<br>  6. Find p s.t. B[i,i+1]!=0 for all i in (p, q), if not found, set p = 0<br>  7. If for any i in (p, q), B[i,i] = 0, remove the off-diagonal at B[i,i+1], <br>     using Givens Left Rotation as described above and update the matrices <br>     P and Q in step 1.<br>  8. If no i exists in (p, q), s.t. B[i,i] = 0, then run Golub Kahan step <br>     for the sub-matrix (p,p) to (q,q) and update the matrices <br>     P and Q in step 1.<br><br>return (P, B, Q)</pre><p>Let U^T be the product of all orthogonal matrices applied so far to the left of A including the Givens left rotation matrices to B and the householder reflection matrices for bidiagonalization from A to B and V product of all orthogonal matrices applied so far to the right of A. Then,</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/291/1*3uucCR3L_sc6dag9dvO1sg.png" /></figure><p>Thus, we have found the SVD for A. S is a diagonal matrix.</p><p>Here is the Rust implementation for the above algorithm:</p><pre>use std::simd::prelude::*;<br>use rand_distr::{Distribution, Normal};<br>use rand::thread_rng;<br>use std::cmp::min;<br>use std::time::SystemTime;<br><br>// Get sub-matrix (r_start, c_start) and (r_end, c_end)<br>fn sub_mat(a:&amp;[f64], _n:usize, m:usize, r_start:usize, r_end:usize, c_start:usize, c_end:usize) -&gt; Vec&lt;f64&gt; {<br>    let mut b:Vec&lt;f64&gt; = vec![0.0;(r_end-r_start+1)*(c_end-c_start+1)];<br>    let u = c_end-c_start+1;<br>    let mut k = 0;<br><br>    for i in r_start..(r_end+1) {<br>        b[k*u..(k+1)*u].copy_from_slice(&amp;a[i*m+c_start..i*m+c_end+1]);<br>        k += 1;<br>    }<br><br>    return b;<br>}<br><br>// Bidiagonalization using Householder transformation<br>fn householder_reflection_bidiagonalization(a:&amp;[f64], n:usize, m:usize) -&gt; (Vec&lt;f64&gt;, Vec&lt;f64&gt;, Vec&lt;f64&gt;) {<br>    let mut q_lt = identity(n);<br>    let mut q_rt = identity(m);<br><br>    let mut r = a.to_vec();<br><br>    for i in 0..min(n, m) {<br>        let n1 = n-i;<br><br>        let mut nm = 0.0;<br>        let mut u = vec![0.0;n1];<br><br>        for i1 in i..n {<br>            nm += r[i1*m+i]*r[i1*m+i];<br>            u[i1-i] = r[i1*m+i];<br>        }<br><br>        u[0] -= sgn(r[i*m+i])*nm.sqrt();<br>        let z = (nm-r[i*m+i]*r[i*m+i]+u[0]*u[0]).sqrt();<br><br>        if z &gt; 0.0 {<br>            for i1 in 0..n1 {<br>                u[i1] = u[i1]/z;<br>            }<br><br>            let mut r1 = vec![0.0;m-i];<br>            for i1 in i..n {<br>                for j1 in i..m {<br>                    r1[j1-i] += u[i1-i]*r[i1*m+j1];<br>                }<br>            }<br><br>            for i1 in i..n {<br>                for j1 in i..m {<br>                    r[i1*m+j1] -= 2.0*u[i1-i]*r1[j1-i];<br>                }<br>            }<br><br>            let mut q1 = vec![0.0;n];<br>            for i1 in i..n {<br>                for j1 in 0..n {<br>                    q1[j1] += u[i1-i]*q_lt[i1*n+j1];<br>                }<br>            }<br><br>            for i1 in i..n {<br>                for j1 in 0..n {<br>                    q_lt[i1*n+j1] -= 2.0*u[i1-i]*q1[j1];<br>                }<br>            }<br>        }<br><br>        if m-i-1 &gt; 0 {<br>            let n1 = m-i-1;<br><br>            let mut nm = 0.0;<br>            let mut u = vec![0.0;n1];<br><br>            for j1 in i+1..m {<br>                nm += r[i*m+j1]*r[i*m+j1];<br>                u[j1-i-1] = r[i*m+j1];<br>            }<br><br>            u[0] -= sgn(r[i*m+i+1])*nm.sqrt();<br>            let z = (nm-r[i*m+i+1]*r[i*m+i+1]+u[0]*u[0]).sqrt();<br><br>            if z &gt; 0.0 {<br>                for i1 in 0..n1 {<br>                    u[i1] = u[i1]/z;<br>                }<br>                <br>                let mut r1 = vec![0.0;n-i];<br>                for i1 in i..n {<br>                    for j1 in i+1..m {<br>                        r1[i1-i] += u[j1-i-1]*r[i1*m+j1];<br>                    }<br>                }<br><br>                for i1 in i..n {<br>                    for j1 in i+1..m {<br>                        r[i1*m+j1] -= 2.0*u[j1-i-1]*r1[i1-i];<br>                    }<br>                }<br>                <br>                let mut q1 = vec![0.0;m];<br>                for i1 in 0..m {<br>                    for j1 in i+1..m {<br>                        q1[i1] += u[j1-i-1]*q_rt[i1*m+j1];<br>                    }<br>                }<br><br>                for i1 in 0..m {<br>                    for j1 in i+1..m {<br>                        q_rt[i1*m+j1] -= 2.0*u[j1-i-1]*q1[i1];<br>                    }<br>                }<br>            }<br>        }<br>    }<br><br>    return (q_lt, r, q_rt);<br>}<br><br>// Get the eigenvalue of the bottom right 2x2 sub-matrix which is closer<br>// to the last diagonal element in B<br>fn eigenvalue_bidiagonal(a:&amp;[f64], n:usize, m:usize, i1:usize, i2:usize, j1:usize, j2:usize) -&gt; f64{<br>    let h = min(i2, j2)+1;<br><br>    let mut d1 = 0.0;<br>    let mut d2 = 0.0;<br>    let mut d3 = 0.0;<br>    let mut d4 = 0.0;<br><br>    if h &gt;= 3 {<br>        d1 = a[(h-3)*m+h-2];<br>    }<br><br>    if h &gt;= 2 {<br>        d2 = a[(h-2)*m+h-2];<br>        d3 = a[(h-2)*m+h-1];<br>    }<br><br>    if h &gt;= 1 {<br>        d4 = a[(h-1)*m+h-1];<br>    }<br><br>    <br>    let a1 = d2*d2 + d1*d1;<br>    let a2 = d2*d3;<br>    let a3 = d2*d3;<br>    let a4 = d4*d4 + d3*d3;<br><br>    let u = 1.0;<br>    let b = -(a1+a4);<br>    let c = a1*a4-a2*a3;<br><br>    let v1 = (-b + (b*b-4.0*u*c).sqrt())/(2.0*u);<br>    let v2 = (-b - (b*b-4.0*u*c).sqrt())/(2.0*u);<br><br>    if (v1-a4).abs() &lt; (v2-a4).abs() {<br>        return v1;<br>    }<br>        <br>    return v2;<br>}<br><br>// Get (c, s) value for Givens right rotation matrix<br>fn givens_right_rotation(a:&amp;[f64], _n:usize, m:usize, i:usize, j:usize, flip:bool) -&gt; (f64, f64) {<br>    let x = a[i*m+j-1];<br>    let y = a[i*m+j];<br>    let r = (x*x + y*y).sqrt();<br><br>    if flip {<br>        return (y/r, -x/r);<br>    }<br><br>    return (x/r, -y/r);<br>}<br><br>// Multiple a by Givens right rotation matrix<br>fn givens_right_rotation_multiply(a:&amp;mut [f64], n:usize, m:usize, c:f64, s:f64, _i:usize, j:usize, r1:usize, r2:usize, c1:usize, c2:usize) {<br>    for i1 in r1..r2+1 {<br>        let p = a[i1*m+j-1];<br>        let q = a[i1*m+j];<br>        a[i1*m+j-1] = c*p - s*q;<br>        a[i1*m+j] = s*p + c*q;<br>    }<br>}<br><br>// Get (c, s) value for Givens left rotation matrix<br>fn givens_left_rotation(a:&amp;[f64], _n:usize, m:usize, i:usize, j:usize, flip:bool) -&gt; (f64, f64) {<br>    let x = a[(i-1)*m+j];<br>    let y = a[i*m+j];<br>    let r = (x*x + y*y).sqrt();<br><br>    if flip {<br>        return (y/r, -x/r);<br>    }<br><br>    return (x/r, -y/r);<br>}<br><br>// Multiple a by Givens left rotation matrix<br>fn givens_left_rotation_multiply(a:&amp;mut [f64], _n:usize, m:usize, c:f64, s:f64, i:usize, _j:usize, r1:usize, r2:usize, c1:usize, c2:usize) {<br>    for j1 in c1..c2+1 {<br>        let p = a[(i-1)*m+j1];<br>        let q = a[i*m+j1];<br>        a[(i-1)*m+j1] = c*p - s*q;<br>        a[i*m+j1] = s*p + c*q;<br>    }<br>}<br><br>// Golub Kahan step on biadiagonal matrix<br>fn golub_kahan(a:&amp;mut [f64], l:&amp;mut [f64], r:&amp;mut [f64], n:usize, m:usize, z:usize, i:usize, j:usize) {<br>    let mu = eigenvalue_bidiagonal(&amp;a, z, z, i, j, i, j);<br>    <br>    let u = a[i*z+i];<br>    let v = a[i*z+i+1];<br>    <br>    a[i*z+i] = u*u-mu;<br>    a[i*z+i+1] = u*v;<br>    <br>    for k in i..j {<br>        let mut x;<br>        let mut y;<br><br>        if k &gt; i {<br>            x = k-1;<br>            y = k+1; <br>        }<br>        else {<br>            x = i;<br>            y = i+1; <br>        }<br><br>        let b = givens_right_rotation(&amp;a, z, z, x, y, false);<br><br>        if k == i {<br>            a[i*z+i] = u;<br>            a[i*z+i+1] = v;<br>        }<br><br>        givens_right_rotation_multiply(a, z, z, b.0, b.1, x, y, i, j, i, j);<br>        givens_right_rotation_multiply(r, m, z, b.0, b.1, x, y, 0, m-1, 0, z-1);<br><br>        if k &gt; i {<br>            x = k+1;<br>            y = k;<br>        }<br>        else {<br>            x = i+1;<br>            y = i;<br>        }<br>            <br>        let b = givens_left_rotation(&amp;a, z, z, x, y, false);<br><br>        givens_left_rotation_multiply(a, z, z, b.0, b.1, x, y, i, j, i, j);<br>        givens_left_rotation_multiply(l, z, n, b.0, b.1, x, y, 0, z-1, 0, n-1);<br>    }<br>}<br><br>// Golub Reisch SVD algorithm<br>pub fn golub_reisch_svd(a:&amp;[f64], mut n:usize, mut m:usize) -&gt; (Vec&lt;f64&gt;, Vec&lt;f64&gt;, Vec&lt;f64&gt;) {<br>    let mut a1 = a.to_vec();<br>    let mut do_transpose = false;<br><br>    if n &lt; m {<br>        do_transpose = true;<br>        a1 = transpose(&amp;a1, n, m);<br>        let g = n;<br>        n = m;<br>        m = g;<br>    }<br>    <br>    let hr = householder_reflection_bidiagonalization(&amp;a1, n, m);<br>    let r = min(n, m);<br><br>    let mut u = sub_mat(&amp;hr.0, n, n, 0, r-1, 0, n-1);<br>    a1 = sub_mat(&amp;hr.1, n, m, 0, r-1, 0, r-1);<br>    let mut v = sub_mat(&amp;hr.2, m, m, 0, m-1, 0, r-1);<br>    <br>    let eps = 1.0e-7;<br>    <br>    loop {<br>        for i in 0..r-1 {<br>            if a1[i*r+i+1].abs() &lt; eps*(a1[i*r+i].abs() + a1[(i+1)*r+i+1].abs()) {<br>                a1[i*r+i+1] = 0.0;<br>            }<br>        }<br><br>        let mut q = 0;<br>        for i in (0..r-1).rev() {<br>            if a1[i*r+i+1].abs() &gt; 0.0 {<br>                q = i+1;<br>                break;<br>            }<br>        }<br><br>        if q == 0 {<br>            break;<br>        }<br><br>        let mut p = 0;<br>        for i in (0..q).rev() {<br>            if a1[i*r+i+1].abs() == 0.0 {<br>                p = i+1;<br>                break;<br>            }<br>        }<br><br>        let mut flag: bool = false;<br>        <br>        for i in p..q {<br>            if a1[i*r+i].abs() == 0.0 {<br>                flag = true;<br>                for j in i+1..r {<br>                    let b = givens_left_rotation(&amp;a1, r, r, i+1, j, true);<br>                    <br>                    givens_left_rotation_multiply(&amp;mut a1, r, r, b.0, b.1, i+1, j, 0, r-1, 0, r-1);<br>                    givens_left_rotation_multiply(&amp;mut u, r, n, b.0, b.1, i+1, j, 0, r-1, 0, n-1);<br>                }<br>            }<br>        }<br><br>        if !flag &amp;&amp; p &lt; q {<br>            golub_kahan(&amp;mut a1, &amp;mut u, &amp;mut v, n, m, r, p, q);<br>        }<br>    }<br><br>    if do_transpose {<br>        return (v, transpose(&amp;a1, r, r), u);<br>    }<br>        <br>    return (transpose(&amp;u, r, n), a1, transpose(&amp;v, m, r));<br>}<br><br>// Multiply matrices inp1 and inp2 using SIMD instructions<br>pub fn matrix_multiply_simd(inp1:&amp;[f64], inp2:&amp;[f64], n:usize, m:usize, p:usize) -&gt; Vec&lt;f64&gt; {<br>    const LANES:usize = 64;<br>    let mut out:Vec&lt;f64&gt; = vec![0.0;n*p];<br><br>    for i in 0..n {<br>        for k in 0..m {<br>            let a:Simd&lt;f64, LANES&gt; = Simd::splat(inp1[i*m+k]);<br>            for j in (0..p).step_by(LANES) {<br>                if k*p+j+LANES &gt; (k+1)*p {<br>                    let mut r:usize = i*p+j;<br>                    for h in k*p+j..(k+1)*p {<br>                        out[r] += inp1[i*m+k]*inp2[h];<br>                        r += 1;<br>                    }<br>                }<br>                else {<br>                    let x:Simd&lt;f64, LANES&gt; = Simd::from_slice(&amp;inp2[k*p+j..k*p+j+LANES]);<br>                    let z:Simd&lt;f64, LANES&gt; = a*x;<br>                    let mut c:Simd&lt;f64, LANES&gt; = Simd::from_slice(&amp;out[i*p+j..i*p+j+LANES]);<br>                    c += z;<br>                    Simd::copy_to_slice(c, &amp;mut out[i*p+j..i*p+j+LANES]);<br>                }<br>            }<br>        }<br>    }<br>    return out;<br>}<br><br>fn main() {<br>    let n = 500;<br>    let m = 500;<br><br>    let mut rng = thread_rng();<br>    let normal:Normal&lt;f64&gt; = Normal::new(0.0, 1.0).ok().unwrap();<br>    <br>    let mut a:Vec&lt;f64&gt; = vec![0.0;n*m];<br><br>    for i in 0..n*m {<br>        a[i] = normal.sample(&amp;mut rng);<br>    }<br><br>    let start_time = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_millis();<br>    let b = golub_reisch_svd(&amp;a, n, m);<br>    let end_time = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_millis();<br>    println!(&quot;{:?}&quot;, end_time-start_time);<br>    <br>    let r = min(n, m);<br>    let mut c = matrix_multiply_simd(&amp;b.0, &amp;b.1, n, r, r);<br>    c = matrix_multiply_simd(&amp;c, &amp;b.2, n, r, m);<br><br>    for i in 0..n*m {<br>        assert!((c[i]-a[i]).abs() &lt; 1e-5, &quot;Some issue in SVD !!!&quot;);<br>    }<br>}</pre><p>The Rust codes are not highly optimized although after build and run with — release flags and compilation with C++ “target-cpu=native”, we saw some pretty good performance for relatively smaller matrices of the order of N=500 and M=500, which are comparable to the “np.linalg.svd” algorithm, which is implemented using LAPACK’s highly optimized FORTRAN routines.</p><pre>RUSTFLAGS=&quot;-C target-cpu=native&quot; cargo run --release</pre><p>Possible optimizations we could do (which may or may not improve performance are):</p><ol><li>Using multithreading for parallel operations.</li><li>Using SIMD instructions for block operations.</li></ol><p>But the above code is not yet ready to handle very small values of the order of 1e-100. One of the reasons being that if a value x ~ O(1e-100), then x² ~ O(1e-200). With such small values, sqrt() method in Rust is going to 0 and thus causing errors in calculations.</p><p>One way is to handle this is to use Taylor Series approximation of sqrt() method when the value is smaller than some threshold.</p><p>For e.g. in the Givens Rotation matrixes, calculating the values of c and s could be done as follows (assuming x &gt; y) :</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*d_hSSKHK-_SsjERD8gQSDQ.png" /></figure><p>We can terminate the series at the point where the absolute value of the i-th term becomes less than say 1% of the total sum.</p><p>The following Rust code can be used to calculate the value of r in the above equations.</p><pre>fn hypot(x:f64, y:f64) -&gt; f64 {<br>    let mut i = 1.0;<br>    let mut s = 1.0;<br>    let mut r = 0.5;<br>    let w;<br>    let u;<br>    if x.abs() &gt; y.abs() {<br>        w = (y/x).abs();<br>        u = x.abs();<br>    }<br>    else {<br>        w = (x/y).abs();<br>        u = y.abs();<br>    }<br>    let mut z = w*w;<br><br>    loop {<br>        let p = (r/i)*z;<br>        if p.abs()/s &lt; 0.01 {<br>            break;<br>        }<br>        s += p;<br>        r *= 0.5-i;<br>        i *= i+1.0;<br>        z *= w*w;<br>    }<br><br>    return s.abs()*u;<br>}</pre><p>Similarly, we should take care of all possible operations wherever we are computing x² terms and probably avoid them.</p><p>Given that the time complexity of the above algorithm is O(NM²), we can try to optimize the run-time performance of the SVD algorithm by using a lower value of M (i.e. number of columns)</p><p>But, how do we choose a lower M ? By projecting the full matrix of M columns into a lower dimension.</p><p>Let P be a matrix of dimensions MxL where L &lt; M. If we multiply A.P, then we will get a new matrix A’ of dimensions NxL. Now if we do SVD on A, the time complexity of the algorithm would be O(NL²). If L &lt;&lt; M, it will improve the run time performance a lot.</p><p>A’ = A.P</p><p>If we do SVD of A, then we will have:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/479/1*qIcRhVI619jkzPoJYNKhEw.png" /></figure><p>To get the SVD of A, we require P to be an orthogonal matrix. How do we choose an orthogonal matrix, is the question here ?</p><p>Instead of choosing an orthogonal matrix, which is a non-trivial operation, we can first find the QR decomposition of A’=A.P i.e.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/262/1*kK-vbZDOS_29NvdIa1H1yA.png" /></figure><p>So, Q is an orthogonal matrix. Instead of multiplying A.P we do Q^T.A i.e.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/488/1*zlk1n9QOx-iRgZ5Xt8M61Q.png" /></figure><p>Now since both Q and U’ are orthogonal matrices, their product is also an orthogonal matrix, thus the matrix U corresponding to A is Q.U’ where U’ is the matrix corresponding to SVD of A’.</p><p>Since A.P is of dimensions NxL, where N &gt; L, Q is also an orthogonal matrix of dimensions NxL (see above) and R is an upper diagonal matrix of dimensions LxL.</p><p>A’=Q^T.A is a matrix of dimensions LxM. We can perform SVD on A’ by first transposing A’ to a MxL matrix where L &lt;&lt; M, and then again transposing the final matrices U, S and V as follows:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/488/1*2y3xGAxbVuKKfCELr5S2ig.png" /></figure><p>The initial matrix P of shape MxL can be chosen from a random normal distribution. This process is popularly known as the Randomzied SVD or Truncated SVD algorithm and is used widely with low rank matrices such as in text classification problems or recommendation problems etc.</p><p>The results obtained after Truncated SVD may or may not be close to the true values of the actual SVD because this is a probabilistic algorithm. On increasing the value of L, the results would be closer to the true SVD of A.</p><p>The task is to find a good balance between speed and accuracy by choosing an optimal value of L.</p><pre>pub fn randomized_svd(a:&amp;[f64], n:usize, m:usize, l:usize) -&gt; (Vec&lt;f64&gt;, Vec&lt;f64&gt;, Vec&lt;f64&gt;){<br>    let mut rng = thread_rng();<br>    let normal:Normal&lt;f64&gt; = Normal::new(0.0, 1.0).ok().unwrap();<br>    <br>    let mut p:Vec&lt;f64&gt; = vec![0.0;m*l];<br><br>    for i in 0..m*l {<br>        p[i] = normal.sample(&amp;mut rng);<br>    }<br><br>    let b = matrix_multiply_simd(&amp;a, &amp;p, n, m, l);<br>    let (mut q, _r, n1, m1, _p1) = givens_rotation_qr(&amp;b, n, l);<br>    let w= min(m1, l);<br>    q = sub_mat(&amp;q, n1, m1, 0, n1-1, 0, w-1);<br><br>    let c = &amp;matrix_multiply_simd(&amp;transpose(&amp;q, n1, w), &amp;a, w, n1, m);<br>    let (mut u, s, v) = golub_reisch_svd(&amp;c, w, m);<br>    u = matrix_multiply_simd(&amp;q, &amp;u, n1, w, w);<br>    return (u, s, v);<br>}</pre><p>Link to the entire code in Github: <a href="https://github.com/funktor/rust_machine_learning/blob/main/src/svd.rs">rust_machine_learning/src/svd.rs at main · funktor/rust_machine_learning (github.com)</a></p><h4>References</h4><ol><li><a href="https://edoliberty.github.io/vector-search-class-notes/class_notes/Class_05_dimensionality_reduction.pdf">Class_05_dimensionality_reduction.pdf (edoliberty.github.io)</a></li><li><a href="https://machinelearningmastery.com/using-singular-value-decomposition-to-build-a-recommender-system/">Using Singular Value Decomposition to Build a Recommender System — MachineLearningMastery.com</a></li><li><a href="https://dmicz.github.io/machine-learning/svd-image-compression/">SVD Image Compression, Explained | dmicz devblog</a></li><li><a href="https://robotics.caltech.edu/~jwb/courses/ME115/handouts/pseudo.pdf">pseudo.pdf (caltech.edu)</a></li><li><a href="https://utminers.utep.edu/xzeng/2017spring_math5330/MATH_5330_Computational_Methods_of_Linear_Algebra_files/ln15.pdf">ln15.pdf (utep.edu)</a></li><li><a href="https://www.cs.utexas.edu/~inderjit/public_papers/HLA_SVD.pdf">C5106_C045.tex (utexas.edu)</a></li><li><a href="https://www.math.chalmers.se/~larisa/www/NumLinAlg/Lecture9_2019.pdf">Applied Numerical Linear Algebra. Lecture 9 (chalmers.se)</a></li><li><a href="https://arxiv.org/pdf/0909.4061">arXiv:0909.4061v2 [math.NA] 14 Dec 2010</a></li><li><a href="https://people.duke.edu/~hpgavin/SystemID/References/Golub+Reinsch-NM-1970.pdf">people.duke.edu/~hpgavin/SystemID/References/Golub+Reinsch-NM-1970.pdf</a></li><li><a href="https://people.inf.ethz.ch/gander/talks/Vortrag2022.pdf">Vortrag2022.pdf (ethz.ch)</a></li><li><a href="https://web.stanford.edu/class/cme324/chan.pdf">chan.pdf (stanford.edu)</a></li><li><a href="https://courses.grainger.illinois.edu/cs554/fa2015/notes/11_qr.pdf">Parallel Numerical Algorithms — Chapter 11 — QR Factorization (illinois.edu)</a></li></ol><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=ac1489eb7ca4" width="1" height="1" alt=""><hr><p><a href="https://medium.com/gaussian-machine/implementing-svd-algorithm-in-rust-ac1489eb7ca4">Implementing SVD algorithm in Rust</a> was originally published in <a href="https://medium.com/gaussian-machine">Gaussian Machine</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Anatomy of a High Level System Design]]></title>
            <link>https://mecha-mind.medium.com/anatomy-of-a-high-level-system-design-a9cf6d6cfd8d?source=rss-3d860626bbc7------2</link>
            <guid isPermaLink="false">https://medium.com/p/a9cf6d6cfd8d</guid>
            <category><![CDATA[high-level-design]]></category>
            <category><![CDATA[interview-questions]]></category>
            <category><![CDATA[system-design-interview]]></category>
            <category><![CDATA[distributed-systems]]></category>
            <category><![CDATA[faang]]></category>
            <dc:creator><![CDATA[Abhijit Mondal]]></dc:creator>
            <pubDate>Fri, 30 Aug 2024 03:22:32 GMT</pubDate>
            <atom:updated>2024-08-30T03:24:22.232Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Abb1FMrApE5pl3BIb3Zn6w.jpeg" /></figure><p>System design interviews can be daunting if a candidate is unprepared. Even if a candidate is prepared well enough, during an interview they might crumble under pressure while structuring their system design and such a situation can lead to following debacles:</p><ol><li>Candidate presents a very unstructured design (although they mention all important points but do not connect them in a logical flow)</li><li>Candidate misunderstands a problem statement and gives a system design of a similar but different system. Candidate does not realizes this until 15–20 minutes into the interview after interviewer probes the design.</li><li>Candidate blacks out in the middle when they realize that their approach is leading to nowhere.</li></ol><p>After giving several system design interviews in both software engineering and machine learning and also taking several interviews at some big tech companies, here are some pointers that candidates should keep handy during an interview.</p><p>Since most interviews nowadays are online, candidates can list down all these pointers in a tab or notepad (most interviewers or companies do not consider this to be cheating).</p><h3>Requirement Gathering</h3><p>This should be the first step towards your solution. Ask the interviewer questions relevant to the problem. Some of these could be:</p><ol><li>Who are the users of the system, are they internal customers, enterprise customers, or general public ?</li><li>Where are the users located, are they geographically distributed or in a specific region ?</li><li>How many concurrent users are going to use this service ?</li><li>How many requests per second the service is expected to handle ?</li><li>Is this a read heavy or write heavy system ?</li><li>Does the system needs to be strongly consistent or can be eventually consistent ?</li><li>If system deals with file uploads/downloads, how large files or data is uploaded or downloaded on an average ?</li></ol><p>… and so on. Most of the questions will be related to the system you are building. But the idea of this exercise is to do :</p><ol><li>Capacity planning i.e. number of CPU cores needed, amount of memory required, throughput, disk space, caching requirements etc.</li><li>Selecting the appropriate database type i.e. SQL vs. NoSQL vs. Graph DB</li><li>Choosing an appropriate database schema i.e. normalized vs. denormalized.</li><li>Choosing the optimum number of database partitions.</li><li>Whether to go for a single leader or multiple leaders configuration.</li><li>Should we go for geo-redundancy or regional redundancy of the service ?</li><li>Will the service require a distributed queueing system to queue the incoming requests ?</li><li>What kind of database indexing engine is appropriate i.e. B-Tree vs. LSM Tree and so on.</li></ol><p>Most of the decisions and tradeoffs are driven by the requirements of the system. All the requirement gathering can be done at the start or as in most practical system design, requirement gathering is a continuous process.</p><h3>High Level Flow Diagram — Read and Write Paths</h3><p>This step requires drawing the different high level components required for a working prototype in production. The different component comprises of but not limited to are:</p><ol><li>Web server(s) to accept the incoming requests</li><li>Load balancer(s) in case multiple web servers are serving requests.</li><li>A queueing system like Kafka to queue the incoming requests in the event that there are too many concurrent requests.</li><li>A rate limiter to handle too many concurrent requests.</li><li>The application server(s) where the main service is running. Again this could be behind some load balancer.</li><li>The database server(s) running the DB service (again possibly behind some load balancer). The database service usually will have multiple partitions and each partition will have multiple read replicas.</li><li>Caching service to serve faster read requests.</li><li>Some cloud based system to collect logs and run monitoring and observability of the different components.</li></ol><p>… and so on. Note that these are just some common high level components that most distributed systems would use.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*5z_IF4_UEeH-PcL9hDphgg.png" /><figcaption>Some “mandatory” boxes in system design</figcaption></figure><h3>API Design</h3><p>Many system design interviews will not focus on API Design but there are some very good companies that focus on API design. This round is specifically to understand how much the candidate understands about different API nuances for example:</p><ol><li>Define the CRUD (Create, Read, Update, Delete) operations for the system.</li><li>Define apis for the CRUD operations.</li><li>How to handle pagination during reads through the APIs ?</li><li>How to handle SQL injection and sanitize user inputs in GET and POST queries ?</li><li>How to serialize and deserialize the POST data into the HTTP request ?</li><li>How to deal with error case scenarios and send appropriate HTTP response codes ?</li></ol><p>…and so on.</p><h3>Database Service</h3><p>This is probably the most “discussed” part in system design interviews in big tech companies as it highlights lots of understanding of distributed system design. The different areas where the interviewers focusses on are as follows:</p><ol><li>Does the service need a distributed database or we can work with a distributed blob storage instead of a database ?</li><li>Choosing the right database technology — SQL vs. NoSQL vs. Graph Databases vs. In Memory databases and the different tradeoffs associated with it.</li><li>Whether a row-oriented filesystem is appropriate or a column oriented filesystem depending on query patterns ?</li><li>Designing the table schemas based on the different read and write patterns.</li><li>Defining different indexing strategies and comparing different data structures such as B-Tree vs LSM Tree.</li><li>How to partition the database, the partitioning columns, number of partitions etc. ?</li><li>How to read and write data across different partitions using Consistent Hashing ?</li><li>How to choose the number of replicas for each database partition ?</li><li>How to select the leader and readers based on the consistency requirements, i.e. strong vs eventual consistency ?</li><li>How to sync multiple leaders ?</li><li>What happens when a replica server crashes and a new replica joins ?</li><li>What happens if a leader crashes and a new leader joins ? How leader election algorithm works ?</li></ol><p>… and so on.</p><p>This section will probably be the longest if you are interviewing at FAANG compaies as there are lots of different strategies and tradeoffs one can discuss here. Good part is that this is also the most popular section among candidates and one can find very good resources over the internet for all of the above.</p><h3>Caching Service</h3><p>If you are designing your system for high throughput, caching is a very critical component. The design of a caching service is quite similar to a database service as often caching is implemented using in-memory databases with limited memory. Some areas where interviewer would want you to focus are:</p><ol><li>Do you need a cache and why ?</li><li>What data are you going to cache and why ?</li><li>What data strcutures are you going to use to cache the data ?</li><li>How are you going to update the cache ?</li><li>What kind of cache updation strategy will you use such as LRU vs LFU for your problem ?</li><li>How many cache servers would be required ?</li></ol><p>… and so on.</p><h3>Quality and Reliability</h3><p>Since reliability of a system is very critical for a product, this section allows the interviewers to evaluate candidates based on how do they think of different edge cases, failure scenarios and so on. Some pointers that the interviewer will look out for:</p><ol><li>How will you debug if your application crashes ?</li><li>How will you setup logging ?</li><li>What different metrics will you monitor from the VMs for e.g. CPU usage, memory usage etc. ?</li><li>How will you autoscale you system based on the monitoring metrics ?</li><li>Will you be setting up geo-redundancy or regional redundancy for your service ?</li><li>How will you deal with issues like missing data or data duplication in your database or cache ?</li><li>How are you going to setup distributed locking for common resources like filesystem ?</li><li>How will you debug situations when write queries fails vs. when read queries fails ?</li></ol><p>… and so on.</p><h3>Security</h3><p>While most companies do not expect candidates to handle security in their system design especially at or below mid-level but for principal and above levels, handling security in you design is of utmost importance. Here are some pointers that interviewers will look out for in your design:</p><ol><li>How to handle and sanitize user inputs before querying the database ?</li><li>How to handle SQL injections ?</li><li>How does authentication and authorization works for each of your component ?</li><li>How are you going to setup private networks for your service and how to do active development over private networks ?</li><li>How are you going to deal with data with various security levels such as general vs public vs confidential etc. ?</li></ol><p>… and so on.</p><p>Incorporating quality and security into your system design will fetch bonus points during the interview.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=a9cf6d6cfd8d" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Implementing L-BFGS optimization from scratch]]></title>
            <link>https://medium.com/gaussian-machine/implement-l-bfgs-optimization-from-scratch-3e5c909f9079?source=rss-3d860626bbc7------2</link>
            <guid isPermaLink="false">https://medium.com/p/3e5c909f9079</guid>
            <category><![CDATA[python]]></category>
            <category><![CDATA[optimization-algorithms]]></category>
            <category><![CDATA[dynamic-pricing]]></category>
            <category><![CDATA[convex-optimization]]></category>
            <category><![CDATA[machine-learning]]></category>
            <dc:creator><![CDATA[Abhijit Mondal]]></dc:creator>
            <pubDate>Thu, 04 Jul 2024 17:51:17 GMT</pubDate>
            <atom:updated>2024-07-05T02:58:43.143Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*in7kUr8UTasZI9ncK_pSCA.jpeg" /><figcaption>source: quanta magazine</figcaption></figure><p>In this series of posts we are going to look at how we can use L-BFGS-B optimization in dynamic pricing algorithms. The posts are inspired by our team’s work at Microsoft in building a distributed and scalable spot virtual machine dynamic pricing engine.</p><p>The posts are broken down as follows:</p><ol><li>Understanding and implementing L-BFGS optimization from scratch.</li><li>Understanding and implementing automatic differentiation from scratch.</li><li>Understanding and implementing L-BFGS-B for bounded constraint problems.</li><li>Improving the run-time performance and memory utilization in the L-BFGS-B optimization algorithm.</li><li>Using L-BFGS-B for dynamic pricing problems with different types of constraints.</li><li>Optimizing our L-BFGS-B implementation to run on GPU.</li><li>Making the L-BFGS-B optimization run in a distributed cluster for large number of parameters.</li></ol><p>In this post, we are going to understand the basics of L-BFGS optimization algorithm and how it compares with some other 1st order and 2nd order optimization algorithms.</p><p>Assuming we are solving an unconstrained optimization problem of a single variable x : minimize f(x).</p><p>Then, we can write f(x) about a point x0 using the Taylor Series as follows:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*dWdvvJgG4XCN79ukvYJvKw.png" /></figure><p>f’(x0) is the 1st order derivative of f w.r.t. x evaluated at x=x0, and similarly f’’(x0) is the 2nd order derivative of f w.r.t. x evaluated at x=x0 and so on.</p><p>Note that the above formula holds true for any arbitrary complex function f(x).</p><p>1st order approximation of f(x) implies that we truncate the above formula for Taylor Series at the 2nd term i.e.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/598/1*STQ0JpWoTMoDhstrYtNxSg.png" /></figure><p>Similarly, 2nd order approximation of f(x) implies that we truncate the formula for Taylor Series at the 3rd term i.e.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/995/1*KXhvtPOq_Mk-qIxlM8FMZg.png" /></figure><p>For any x, which is very close to x0 i.e. x-x0 -&gt; 0, then the higher order terms such as (x-x0)³, (x-x0)⁴ and so on becomes very small and thus the 2nd order approximation is very close to the actual value of f(x).</p><p>Remember that to find the minimum value of a function f(x) (assuming f is continuous and both f’(x) and f’’(x) exists), we take the 1st derivative of f(x) w.r.t. x and set them to 0, i.e.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/202/1*BkUwbCfH13IeoYqmPcjp9w.png" /></figure><p>Once we solve the the above equation and find the corresponding x_opt at which f’(x_opt) = 0, we can then evaulate the 2nd order derivative at x_opt:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/268/1*OWGRSRiZJ0ZWvy1A3pkH3g.png" /></figure><p>If the 2nd order derivative is greater than 0, then x_opt is a minima. If the 2nd order derivative is less than 0, then x_opt is a maxima. Note that if f(x) is a convex function, then there is only one global minima, but if f(x) is non-convex, then f(x) can have multiple local minima apart from a global minima.</p><p>The condition f’(x_opt) = 0 and f’’(x_opt) &gt; 0 holds true for local minima. Thus, with non-convex functions, x_opt can be a local minima.</p><p>Solving the equation f’(x) = 0, when f(x) is some complex function may not be always feasible or can be too complicated. Thus, we cannot always use f(x) in its original form to find x_opt.</p><p>In such scenarios, the Taylor Series approximations comes handy.</p><p>For the 1st order approximation scenario:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/598/1*STQ0JpWoTMoDhstrYtNxSg.png" /></figure><p>Denoting h = x-x0, we have:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/572/1*Q4ZAu_XNjRmdOVzdBQqRBw.png" /></figure><p>We want to find an optimum h, s.t. f(x0+h) is minimum among all possible values of h. Note that we cannot use the method of solving df/dh = 0 and then checking d²f/dh² &gt; 0 because in the above formula, the 2nd derivative d²f/dh² do not exist.</p><p>Alternatively, we can find h s.t. f(x0+h) &lt; f(x0), i.e.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/495/1*L5O7Jnj3hV8sGn0JNZUyqw.png" /></figure><p>Thus, f’(x0)*h must be less than 0.</p><p>If we choose h = -a*f’(x0) where a &gt; 0, then we have f’(x0)*h = -a*(f’(x0)² which is &lt; 0 because both a and (f’(x0)² &gt; 0.</p><p>Thus, if we choose h = -a*f’(x0), or the next point from x0 as x1 = x0-a*f’(x0), then we will find f(x1) which is smaller than f(x0). This gives us an idea that if we move to x1 then we can lower the value from f(x0) and can bring us closer to the minima.</p><p>If we use the following update strategy for x_n in the n-th iteration, then:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/438/1*JkeKdhPnl8QyKzT3LBFfRg.png" /></figure><p>This is what usually happens in the gradient descent algorithm, where ‘a’ is the learning rate.</p><p>Note that we have to carefully choose the constant ‘a’.</p><p>If a is too high, then the next parameter x_n+1 might overshoot the minima and thus it might happen that either the iterations oscillates and do not converge at all or it converges but very slowly.</p><p>If ‘a’ is too low, then although the iterations will converge to a local minima, but it might be very slow.</p><p>Another approach one can take is instead of using a constant value for ‘a’, one can update ‘a’ in each iteration. One can use heuristics to come up with satisfactory values for ‘a’ such as the sufficient decrease condition:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/902/1*0kdqVwfW19BNBji2eOMDag.png" /></figure><p>It says that after an update to x0, f(x1) should be “sufficiently” reduced w.r.t. f(x0) so that we do not converge towards the minima too slow. Another way to look at this is that if we plot the curve of f w.r.t. a, then the curve lies below the line f(x0) — c1*a*(f’(x0))², where the slope of the line is -c1*(f’(x0))².</p><p>The other is the curvature condition:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/579/1*5kFzDhFhmdRd1MOUB9cClQ.png" /></figure><p>It says that, if we use the same descent direction -f’(x0) at both x0 and x1, then at x1, the angle between the descent direction and the slope is larger than the angle between the decent direction and the slope at x0. It implies that at x1, we are more closer to the minima than at x0.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/964/1*SKRJoMqadQKSTc8nv4k4Ug.png" /></figure><p>In general, these conditions are known as the Wolfe Conditions and are used in the Line Search algorithm we will see later in this post.</p><p>If f(x) is a convex function, then it is guarenteed to converge to a global minima when the values of ‘a’ are small enough. For non-convex functions, most often we will land up in a local minima with some initial starting point x0. One needs to run the iterations with different initial points to be able to reach a “better” minima or a global minima.</p><p>Often the 1st order approach we took above can be slow to reach a local minima. To overcome this, one usually uses a 2nd order approximation to the Taylor Series to compute the descent directions.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/889/1*OL_ohIulId3uJCYLfDESJg.png" /></figure><p>In this case, we can use the approach where we set df/dh = 0 and check if d²f/dh² &gt; 0 because d²f/dh² exists.</p><p>df/dh = 0 implies that :</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/769/1*ZKRpDHYQIN2FQlhTqUqtbA.png" /></figure><p>If we take the 2nd derivative of f w.r.t. h, we get: d²f/dh² = f’’(x0). Thus in order for f(x0 + h) to be a minima, we also need to satisfy f’’(x0) &gt; 0.</p><p>Using the 2nd order approximation, the update to x_n are as follows:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/858/1*URy7zFZraOt-ZpyrzqmAhw.png" /></figure><p>To find the optimum value of ‘a’ at each iteration we can perform the above mentioned line search.</p><p>Instead of a function of a single parameter x, we can similarly solve the above optimization for functions taking a vector of parameters X. The 1st derivative is replaced by the gradient vector and the 2nd derivative by the Hessian matrix.</p><p>For the 2nd order approximation, we can rewrite the equations in vector and matrix terms as follows:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/646/1*HA6EMit_JplgUthInYY7XQ.png" /></figure><p>X0 is a vector of size m, h is another vector of size m, f is the optimization function that takes in a vector of size m and returns a scalar.</p><p>g is the gradient vector and g^T is the transpose of the vector.</p><p>H is the hessian matrix and is of size m x m:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*BRnpxWmQCENDrB0N35rN8Q.png" /></figure><p>The update equation looks like:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*JLCQ087ok-ayugmMwkhBOg.png" /></figure><p>The corresponding condition f’’(x0) &gt; 0 for a vector of parameters X0, is that the hessian matrix H is positive definite matrix i.e. for any non-zero vector x, we have the following:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/255/1*EGTQ8PRDu85hFHnWW6devA.png" /></figure><p>If the above condition is satisfied for the hessian matrix H, then we can move in the direction -B_n.g_n to reduce the value of the optimization function.</p><p>When the number of parameters i.e. the size of the vector X, m is very large ~ 1⁰⁶ or so, storing and computing the hessian matrix or its inverse is not scalable. To store a matrix of 1⁰¹² floating point values each of 64 bits, would require approximately 8TB of RAM.</p><p>Also, computing the inverse of a m x m matrix has a time complexity of O(m³).</p><p>In the BFGS approach, instead of computing the inverse of the hessian, its values are approximated using the below formula.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/887/1*_55TwKyp285mxSD-amNJtg.png" /></figure><p>where we have the following:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/991/1*Vrp2E0V0Wf2krEFG0GpN1A.png" /></figure><p>Thus, we see that the inverse hessian matrix at iteration n+1 i.e. B_n+1 is only dependent on the hessian at iteration n, the difference between the gradients at iterations n and n-1 and the difference between the parameters X at iterations n and n-1.</p><p>Now, we do not need to compute the inverse hessian matrix. The initial inverse hessian i.e. B0 can be taken as the identity matrix. Another nice property of the above updates are that if B0 is positive definite, then B_n is positive definite for all n &gt; 0.</p><p>But even with this modifications, we still have to keep the matrix B_n of size m x m in memory.</p><p>In the Limited Memory BFGS approach, we do not need to store the B_n matrices except for B0 which is an identity matrix of size m. Instead of B_n+1 using the recursion from the n-th iteration, if we recompute all of the previous values at each iteration n i.e.</p><p>Let:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/641/1*hfYloCo0AjZYicXEzHruoA.png" /></figure><p>Then we can write by expanding B_n in the update equation as follows:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/943/1*VcyB0eL3nUw65soaWZ7A5g.png" /></figure><p>Similarly, expanding the B_n-1 term completely, we get the following:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/849/1*CjiQErOjM47a6UNlUqEceQ.png" /></figure><p>where</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Ii3EigSlLQAQSeRErKF39g.png" /></figure><p>Note that for each iteration n, we have to compute the entire product from iteration 0 to n-1. Moreover, we need to keep all the values y_n and s_n in memory to compute the above terms.</p><p>If the number of iterations required to converge are very high, then the amount of memory required as well as the time complexity of this approach will very well exceed the vanilla BFGS strategy where we just needed to keep the inverse hessian matrix from last iteration in memory.</p><p>In L-BFGS, instead of using all the previous iterations, only the latest k iterations’ values are considered and the rest are discarded i.e.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/995/1*fD-HA4v3awvyefDIZVLZqA.png" /></figure><p>Similarly, we use only the latest k values for Wn.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*mjIAlhKbvfQo3Jl_U9CtIQ.png" /></figure><p>Note that this is an approximation and usually for k between 5 and 10, the inverse hessian computed with latest k values are very much close to the true inverse hessian.</p><p>Let’s estimate the time and space complexity with this approach.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*JLCQ087ok-ayugmMwkhBOg.png" /></figure><p>Instead of computing B_n and then multiplying with the gradient vector g_n, we can calculate B_n.g_n together. This is because when we are computing B_n beforehand, we need to store a matrix of size m x m in memory which we saw earlier would take up around 8 TB of RAM.</p><p>Instead if we compute the following:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/463/1*6uvMPIx-7GR1YTobsTjJ4g.png" /></figure><p>Computing the dot product yT_n.g_n, where yT_n is of dimensions 1 x m and g_n is of dimensions m x 1, would result in a scalar quantity. Thus uT_n.g_n is a vector of dimension m x 1 and only requires keeping O(m) values in memory.</p><p>Next we take the dot product of the above quantity with uT_n-1.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*TJleQfxR-1WynZW1BaMGNA.png" /></figure><p>Thus, again we are only storing O(m) data in memory. Continue the above multiplications till we multiply with the matrix B0. Since B0 is an identity matrix, the multiplication of B0 with the above chain of multiplications results in a vector of size m x 1.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/867/1*Bb_0VhH8Q_5ZQQB8K45iMw.png" /></figure><p>The analysis is same for W_n.g_n. Thus, we see that the space complexity of doing the dot product of B_n.g_n is O(m). To compute the terms u_n and w_n, we need to keep the last k pairs of y_n and s_n in memory i.e. a space complexity of O(km).</p><p>Thus the overall space complexity of the above approach is O(k*m), where k is the maximum history we are using.</p><p>Time complexity is O(k²m) because when we are taking the dot product of W_n with g_n. There are k terms in W_n and for each term, taking the dot product with g_n has time complexity of O(km), infact it is O(jm) for the j-th term in W_n.</p><p>But since k = O(10), the overall time complexity per iteration can still be considered to be O(m).</p><p>Once we have obtained the dot product B_n.g_n, then we can go ahead and update the parameter vector X_n+1 as follows:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/450/1*CGbesHUljAkmLSDY16S_Hg.png" /></figure><p>Only thing remaining is finding the optimum value for the constant ‘a’. There are several ways to calculate the value of ‘a’.</p><p>One such strategy is as follows:</p><p>Let us define: F(a)=f(X_n)-c1*a*B_n.g_n</p><p>For any 2 step sizes, a_low and a_high, if we take some step in between them, let’s say a_mid = (a_low + a_high)/2.</p><p>If F(a_mid) &gt; f(X_n+1), then a_mid is a ‘good’ step and we can search for larger step sizes in the interval (a_mid, a_high]. If we get any ‘good’ step in the updated interval then we take that else we use the current ‘good’ step.</p><p>Similarly, if F(a_mid) ≤ f(X_n+1), then a_mid is not a ‘good’ step. In this case, we can search in the low interval [a_low, a_mid). But it may happen that there are ‘good’ steps in (a_mid, a_high] but not in [a_low, a_mid).</p><p>If we do not find any ‘good’ step in [a_low, a_mid), then we return some default small value for ‘a’ as the step size.</p><p>A ‘good’ step is defined based on the Wolfe conditions shown below:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*P7KoWKr_1mDtdk6Gqh4MQg.png" /></figure><p>Let the maximum value of a = 1.0 and a_default = 1e-7, then a_low = 0.0 and a_high = 1.0</p><ol><li>Find a_mid = (a_low + a_high)/2</li><li>Calculate X_n+1 and g_n+1 using a_mid in the above formula.</li><li>If the sufficient decrease condition is satisfied then a_mid is a “good” candidate for ‘a’ and we try to search for larger values of ‘a’ s.t. the conditions still holds good by setting a_low = a_mid + 1e-7 and repeat from step 1.</li><li>In step 3 if for a_mid, the curvature condition is also satisfied, then we return a_mid as the optimum value for a.</li><li>If the sufficient decrease condition do not satisfy, then reduce the value of ‘a’ by setting a_high = a_mid — 1e-7 and repeat from step 1.</li></ol><p>Once we have found the optimum value of X_n, we can stop iterating when:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/545/1*MvB2V2BNiJpbJqdMegy2Ow.png" /></figure><p>Let’s get down to some coding.</p><p>Python implementation of the above L-BFGS algorithm applied on the 2D Rosenbrock function:</p><pre>import numpy as np<br>from collections import deque<br><br>def fn(x):<br>    # Compute the optimization function<br>    return (1.0-x[0,0])**2 + 100*(x[1,0]-x[0,0]**2)**2<br><br>def gfn(x):<br>    # Gradient of fn(x)<br>    return np.array([[-2*(1.0-x[0,0]) - 400*x[0,0]*(x[1,0]-x[0,0]**2)], <br>                    [200*(x[1,0]-x[0,0]**2)]])<br><br>def line_search(x, g, d, a0=1e-5, e=1e-6):<br>    # Perform line (binary) search to find optimal value of a<br>    left, right = 0.0, 1.0<br>    a, c1, c2 = a0, 1e-4, 0.9<br>    <br>    while left &lt; right:<br>        m = (left + right)/2<br>        <br>        x_new = x - m*d<br>        g_new = gfn(x_new)<br><br>        # sufficient decrease condition<br>        if fn(x_new)-fn(x) &lt; -c1*m*np.dot(g.T, d):<br>            # curvature condition<br>            if abs(np.dot(d.T, g_new)) &lt; c2*abs(np.dot(d.T, g)):<br>                return m<br>                <br>            a = m<br>            left = m + e<br>        else:<br>            right = m - e<br>            <br>    return a<br>    <br>def optimize(x0, k=10, epsilon=1e-7, max_iter=1e5):<br>    # l-bfgs optimization algorithm<br>    m = len(x0)<br><br>    x = x0<br>    g = gfn(x)<br>    <br>    # Store the last k values of y and s vectors<br>    prev_values = deque([(x, g)])<br><br>    while max_iter &gt; 0:<br>        print(f&quot;Current objective value = {fn(x)}&quot;)<br>        max_iter -= 1<br>        <br>        prev_x = x<br>        prev_g = g<br><br>        q = g<br>        for i in range(len(prev_values)-1, -1, -1):<br>            y, s = prev_values[i]<br>            r = 1.0/np.dot(y.T, s)<br>            q = q - r*np.dot(y.T, q)*s<br><br>        for i in range(len(prev_values)):<br>            y, s = prev_values[i]<br>            r = 1.0/np.dot(y.T, s)<br>            q = q - r*np.dot(s.T, q)*y<br><br>        p = 0<br>        for i in range(len(prev_values)-1, -1, -1):<br>            w = g<br>            <br>            for j in range(len(prev_values)-1, -1, -1):<br>                if j &gt; i:<br>                    y, s = prev_values[j]<br>                    r = 1.0/np.dot(y.T, s)<br>                    w = w - r*np.dot(y.T, w)*s<br>                else:<br>                    break<br>                    <br>            y, s = prev_values[i]<br>            r = 1.0/np.dot(y.T, s)<br>            w = r*np.dot(s.T, w)*s<br><br>            for j in range(len(prev_values)):<br>                if j &lt; len(prev_values)-1-i:<br>                    y, s = prev_values[j]<br>                    r = 1.0/np.dot(y.T, s)<br>                    w = w - r*np.dot(s.T, w)*y<br>                else:<br>                    break<br>            p += w<br><br>        # Descent direction<br>        d = p+q<br><br>        # Perform line search<br>        a = line_search(x, g, d)<br>        <br>        x = x - a*d<br>        g = gfn(x)<br><br>        y = x-prev_x<br>        s = g-prev_g<br>        <br>        prev_values.append((y, s))<br><br>        # Maintain only latest k values<br>        if len(prev_values) &gt; k:<br>            prev_values.popleft()<br><br>        # Exit condition<br>        if np.abs(y).all() &lt; epsilon:<br>            break<br>        <br>    return x<br><br>if __name__ == &#39;__main__&#39;:<br>    x0 = np.array([[0.003], [0.006]])<br>    x = optimize(x0, k=10, epsilon=1e-7)<br>    print(f&quot;Final solution = {x}&quot;)</pre><p>In the above code, we had to explicitly specify the gradient function because the gradient is specific to the objective function and for each new function we have to update the gfn(x) gradient function. Also if the objective function is complicated, then finding the derivative calculations can be erroneous and cumbersome.</p><p>In the next post we will see how we can use the automatic differentiation technique to automatically find the gradient without having to update it for every new function.</p><p>References:</p><ol><li><a href="https://aria42.com/blog/2014/12/understanding-lbfgs">Numerical Optimization: Understanding L-BFGS — aria42</a></li><li><a href="https://users.iems.northwestern.edu/~nocedal/PDFfiles/limited-memory.pdf">paper.dvi (northwestern.edu)</a></li><li><a href="https://users.iems.northwestern.edu/~nocedal/PDFfiles/representations.pdf">paper.dvi (northwestern.edu)</a></li><li><a href="https://www.ams.org/journals/mcom/1980-35-151/S0025-5718-1980-0572855-7/S0025-5718-1980-0572855-7.pdf">S0025–5718–1980–0572855–7.pdf (ams.org)</a></li><li><a href="https://en.wikipedia.org/wiki/Backtracking_line_search">Backtracking line search — Wikipedia</a></li><li><a href="https://en.wikipedia.org/wiki/Wolfe_conditions">Wolfe conditions — Wikipedia</a></li><li><a href="https://en.wikipedia.org/wiki/Line_search">Line search — Wikipedia</a></li><li><a href="https://pages.cs.wisc.edu/~yudongchen/cs726_sp24/Lecture_20_line_search_modified_Newton.pdf">Lecture 20: Line Search Procedures; Newton’s Method with Hessian Modification (wisc.edu)</a></li></ol><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=3e5c909f9079" width="1" height="1" alt=""><hr><p><a href="https://medium.com/gaussian-machine/implement-l-bfgs-optimization-from-scratch-3e5c909f9079">Implementing L-BFGS optimization from scratch</a> was originally published in <a href="https://medium.com/gaussian-machine">Gaussian Machine</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Automatic Differentiation from scratch]]></title>
            <link>https://medium.com/gaussian-machine/automatic-differentiation-from-scratch-3f95260377f5?source=rss-3d860626bbc7------2</link>
            <guid isPermaLink="false">https://medium.com/p/3f95260377f5</guid>
            <category><![CDATA[optimization-algorithms]]></category>
            <category><![CDATA[autograd]]></category>
            <category><![CDATA[machine-learning]]></category>
            <category><![CDATA[neural-networks]]></category>
            <category><![CDATA[automatic-differentiation]]></category>
            <dc:creator><![CDATA[Abhijit Mondal]]></dc:creator>
            <pubDate>Thu, 04 Jul 2024 17:34:21 GMT</pubDate>
            <atom:updated>2024-07-04T17:57:17.682Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*IZDu2aaSK__qITP66X5UkA.jpeg" /><figcaption>source: quanta magazine</figcaption></figure><p>In the previous part of this series, we understood L-BFGS optimization algorithm and implemented the same from scratch in Python.</p><p><a href="https://mecha-mind.medium.com/implement-l-bfgs-optimization-from-scratch-3e5c909f9079">Implement L-BFGS optimization from scratch | by Abhijit Mondal | Jul, 2024 | Medium</a></p><p>One of the issues in the implementation we found was that, we needed to explicitly define the gradient for the objective function. While this works for simple objective functions, but for complicated functions calculating gradient can be tedious and erroneous.</p><p>For dynamic pricing, the objective function can be quite complicated with millions of parameters interacting with one another. In this post, we take a detour from L-BFGS and show an automatic method for computing the gradients without explicitly calculating the derivatives.</p><p>Recall, that from the 1st order Taylor series approximation, we can write the following:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/522/1*XX0dwRl6WV14FwiArbnRjQ.png" /></figure><p>The above approximation holds good when h is very small and is close to 0.</p><p>Then, we can compute the derivative f’ at some point x, using the above formula as:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/470/1*eFPXEnZxQY8cCbQZob-OSQ.png" /></figure><p>Thus, we can use the above strategy to find the derivative of the objective function f at some point x. But the above has few problems such as:</p><ol><li>Holds good only in the limit h-&gt;0. On most computers it can cause truncation errors. For e.g. if h is very small, then f(x+h) — f(x) would be very small and the computer will make it 0. Thus f’(x) would be 0.</li><li>If the function f(x) is complicated and require lots of CPU cycles, we are computing f 2 times for derivative. Now, if x is a vector of parameters of size n, then computing the gradient for each parameter would require 2n calculations.</li><li>Even if the error from the above approach is small, the errors can propagate and can blow up. For e.g. in neural networks with 1000s of layers, the error will propagate to all weights and all layers eventually, leading to significant errors in calculations.</li></ol><p>To overcome the above issues, researchers have come up with the “automatic differentiation” approach. There are 2 different types of automatic differentiation strategies:</p><p>Forward mode and Reverse mode.</p><p>The forward mode is most intuitive and works as follows:</p><p>Given a function, which is a composition of multiple functions, for e.g. f(g1(h1(x,y), h2(y)), g2(h3(x,y), h4(x))). Then we can find the partial derivatives of f w.r.t. x and y as follows:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*9z17YJ-DPQ8CHS3fLwOdDg.png" /></figure><p>If we draw the computation graph for the above function:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/941/1*-VZV82ZqGdz3c8mTUDSiEQ.png" /></figure><p>Then we can obtain the partial derivatives of f w.r.t. x and y by backtracking from f in the above diagram to the corresponding nodes x and y. Note that we have to backtrack across all paths that lead to either x or y.</p><p>The backtracking paths are shown in red in the below diagram for partial derivative w.r.t. x.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/944/1*0xhmKJbOxrGQ0RyrIP3giA.png" /></figure><p>For each edge from a to b in red, if we take the derivative da/db and take the product of all the derivatives for each edge in a given path and then sum over all paths, we will obtain the partial derivative formulae described above.</p><p>Thus, either we can use recursion or find the topological sorting of the computational graph to calculate the partial derivatives.</p><p>For e.g. using recursion we can write, G(f,x) = (df/dg1).G(g1,x) + (df/dg2).G(g2,x) where G(f,x) is the partial derivative of f w.r.t. x. or in general:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*H0thRNM5qS09APwJp06aww.png" /></figure><p>Another way to represent the above partial derivatives is through Jacobian matrices. Let us represent the following Jacobian matrices:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*r32ZpxLLWPzPq0kvA-iIwQ.png" /></figure><p>Then we can obtain the partial derivatives df/dx and df/dy simply by multiplying the above Jacobians.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/423/1*5c00jWW3eZBVa_pvF5_z4g.png" /></figure><p>Let’s look at the time complexity.</p><p>Let us assume that Jh is a matrix of size m1 x n (where n is the number of inputs and here n=2 i.e. x and y)</p><p>Let the dimension of Jg be m2 x m1 (The number of columns is m1 because g is a function of h and h has m1 rows).</p><p>Similarly, Jf will have m2 columns, but has only 1 row hence dimension is 1 x m2</p><p>Assuming that we are doing forward mode automatic differentiation, then the Jacobian Jh will be calculated first (due to recursion in the computational graph), then Jg and then Jf and thus the multiplication happens from right to left. Thus the time complexity is</p><p>O(m2*m1*n + 1*m2*n) = O(n³) assuming m1 = m2 = O(n).</p><p>Instead if we have done the matrix multiplications from left to right, the time complexity would have been:</p><p>O(1*m2*m1 + 1*m1*n) = O(n²)</p><p>This is only possible if we compute the Jacobian Jf first and then Jg and lastly Jh, which requires us to compute the automatic differentiation in the reverse order which is known as the Backward or Reverse mode of automatic differentiation.</p><p>An efficient way to calculate the Jacobians in the reverse mode is to first evaluate each function for the given inputs and then cache the function results and use them for differentiation. For e.g. in the above example given x and y as inputs calculate the following in the order:</p><ol><li>h1, h2, h3 and h4</li><li>g1 and g2</li><li>f</li></ol><p>Let us call it the forward pass.</p><p>Once we cache the results of the above function calls, then we can directly use them in the partial derivatives. For e.g. df/dg1 is simply the derivative of f w.r.t. g1 evaluated at g1 calculated in the forward pass.</p><p>Something similar happens in Neural Networks where you can assume that the (x, y) is the input layer, (h1, h2, h3, h4) is the 1st hidden layer, (g1, g2) is the next hidden layer and f is the output unit. Usually we make a forward pass first with x and y and then do backpropagation in the reverse order.</p><p>Thus the reverse mode of automatic differentiation is essentially the backpropagation algorithm.</p><p>Firstly, lets start by implementing the forward mode of automatic differention. Then we will gradually go and implement the reverse mode.</p><p>But the question is how to form the computational graph given any objective function. Let’s take an example:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/832/1*BklBIi0qMSEcJDTI1Uynrg.png" /></figure><p>Here we have only a single input variable x. Here is a way to break up that complicated looking function into intermediate functions as follows:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/841/1*9LPbpmF32TUsWMhC8Lo9yA.png" /></figure><p>The computational graph will look as follows:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1015/1*C0YDQDQG5aN77Bq2rMo6Fw.png" /></figure><p>Note that after breaking up the function f into the above computational graph, we have obtained much simpler functions such as sin, cos, ln, exp, x^k etc. apart from addition and multiplication.</p><p>Computing the derivatives of the simpler functions is straightforward and moreover we can explictly define the derivatives for such functions given that there will be a handful of basic operations:</p><p>sin, cos, tan, exp, pow, log should cover most useful objective functions.</p><p>Here is a simple Python implementation of automatic differentiation for a single variable from scratch:</p><pre>from functools import wraps<br>import math<br><br># Automatic differentiation of exp(1 + x^4*(cos(log(x)) + sin(log(x))))<br><br>class Node:<br>    def __init__(self):<br>        self.function = None<br>        self.d_function = None<br><br>    def __add__(self, other):<br>        return add(self, other)<br><br>    def __sub__(self, other):<br>        return sub(self, other)<br><br>    def __mul__(self, other):<br>        return mul(self, other)<br><br>    def __truediv__(self, other):<br>        return div(self, other)<br><br>    def __call__(self, *args):<br>        return self.function(*args)<br><br>class Variable:<br>    def __init__(self):<br>        pass<br>        <br>    def __new__(cls):<br>        cls.node = Node()<br>        cls.node.function = lambda z: z<br>        cls.node.d_function = lambda : Constant(1.0)<br>        return cls.node<br><br>class Constant:<br>    def __init__(self, val):<br>        pass<br>        <br>    def __new__(cls, val):<br>        cls.node = Node()<br>        cls.node.function = lambda z: val<br>        cls.node.d_function = lambda : Constant(0.0)<br>        return cls.node<br><br>def my_decorator(f):<br>    @wraps(f)<br>    def wrapper(*args, **kwds):<br>        node = Node()<br>        new_args = []<br><br>        for x in args:<br>            if isinstance(x, Node):<br>                new_args += [x]<br>            else:<br>                raise Exception(&quot;Invalid input parameter !!!&quot;)<br><br>        evaluate, derive = f(*new_args, **kwds)<br>        <br>        node.function = evaluate<br>        node.d_function = derive<br>        <br>        return node<br>        <br>    return wrapper <br><br>@my_decorator<br>def cos(x):<br>    def evaluate(p):<br>        return math.cos(x.function(p))<br>    def derive():<br>        return Constant(-1.0)*sin(x)*grad(x)<br>    return evaluate, derive<br><br>@my_decorator<br>def sin(x):<br>    def evaluate(p):<br>        return math.sin(x.function(p))<br>    def derive():<br>        return cos(x)*grad(x)<br>    return evaluate, derive<br><br>@my_decorator<br>def log(x):<br>    def evaluate(p):<br>        return math.log(x.function(p))<br>    def derive():<br>        return (Constant(1.0)/x)*grad(x)<br>    return evaluate, derive<br><br>@my_decorator<br>def exp(x):<br>    def evaluate(p):<br>        return math.exp(x.function(p))<br>    def derive():<br>        return exp(x)*grad(x)<br>    return evaluate, derive<br><br>@my_decorator<br>def pow(x, y):<br>    def evaluate(p):<br>        return math.pow(x.function(p), y.function(p))<br>    def derive():<br>        return y*pow(x, y-Constant(1))*grad(x)<br>    return evaluate, derive<br><br>@my_decorator<br>def add(x, y):<br>    def evaluate(p):<br>        return x.function(p) + y.function(p)<br>    def derive():<br>        return grad(x)+grad(y)<br>    return evaluate, derive<br><br>@my_decorator<br>def sub(x, y):<br>    def evaluate(p):<br>        return x.function(p) - y.function(p)<br>    def derive():<br>        return grad(x)-grad(y)<br>    return evaluate, derive<br><br>@my_decorator<br>def mul(x, y):<br>    def evaluate(p):<br>        return x.function(p) * y.function(p)<br>    def derive():<br>        return x*grad(y) + y*grad(x)<br>    return evaluate, derive<br><br>@my_decorator<br>def div(x, y):<br>    def evaluate(p):<br>        return x.function(p) / y.function(p)<br>    def derive():<br>        return Constant(-1.0)*x*pow(grad(y), Constant(-2.0)) + pow(y, Constant(-1.0))*grad(x)<br>    return evaluate, derive<br><br>@my_decorator<br>def grad(f):<br>    def evaluate(p):<br>        return f.d_function().function(p)<br>    def derive():<br>        return f.d_function().d_function()<br>    return evaluate, derive<br><br>def standard_fn(x):<br>    f1 = math.log(x)<br>    f2 = math.cos(f1)<br>    f3 = math.sin(f1)<br>    f4 = f2 + f3<br>    f5 = math.pow(x, 4.0)<br>    f6 = f5*f4<br>    f7 = math.exp(1.0+f6)<br>    return f7<br><br>def grad_numerical(f, x, h=1e-6):<br>    return (f(x+h)-f(x))/h<br><br>def grad_ad(x):<br>    y = Variable()<br>    f = exp(Constant(1.0) + pow(y, Constant(4.0))*(cos(log(y)) + sin(log(y))))<br>    return grad(f)(x)<br><br>if __name__ == &#39;__main__&#39;:<br>    y = Variable()<br>    z = pow(y, Constant(5))<br>    assert z(2) == 32<br>    assert grad(z)(3) == 405<br>    assert grad(grad(z))(4) == 1280<br>    assert grad(grad(grad(z)))(5) == 1500<br><br>    x = 0.679<br>    ad = grad_ad(x)<br>    print(f&quot;Autograd = {ad}&quot;)<br>    nm = grad_numerical(standard_fn, x)<br>    print(f&quot;Numerical grad = {nm}&quot;)<br>    ex = math.exp(1.0 + (x**4)*(math.cos(math.log(x)) + math.sin(math.log(x))))*(4*(x**3)*(math.cos(math.log(x)) + math.sin(math.log(x))) + ((x**4)*(-math.sin(math.log(x))/x + math.cos(math.log(x))/x)))<br>    print(f&quot;Exact grad = {ex}&quot;)</pre><p>The idea is as follows:</p><p>We are wrapping the conventional python math functions inside a Node object. Each Node object has a “function” method and a “d_function” method. The “function” method evaluates the corresponding math function at some value p. “d_function” method returns a Node object corresponding to the derivative function.</p><p>Taking an example from above:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/832/1*BklBIi0qMSEcJDTI1Uynrg.png" /></figure><p>The corresponding representation of the above function in our implementation would be:</p><pre>x = Variable()<br>sin(exp(x)+pow(x, Constant(3.0))) + \<br>  pow(x, Constant(2.0))*cos(Constant(1.0) + log(exp(x) + pow(x, Constant(3.0))))</pre><p>Calling one of these functions sin(f(x)), cos(f(x)), pow(f(x), g(x)), log(f(x)), will first call the ‘my_decorator’ decorator which creates a Node object and checks if the input arguments are Node objects or not. Each argument must be a Node object, whether its another function, a variable or a constant.</p><p>After checking that all arguments are Node objects, execute the code inside the called method and return the “function” and “d_function” methods and attach them to the new Node object.</p><p>The above implementation for automatic differentiation for the given function may not be most optimal because note that the term (e^x + x³) occurs twice in the function f(x).</p><p>Instead of creating 2 different Node objects corresponding to (e^x + x³), we could have created a single Node object and re-used it like in the computational graph where x3 is used by both x4 and x6. Moreover we could cache the results of the “function” call at each Node.</p><p>Now let’s update the code to handle a vector of parameters and compute partial derivatives w.r.t. one of the parameters at a time. Also, adding caching of function results.</p><pre>from functools import wraps<br>import math, numpy as np<br><br>class Node:<br>    def __init__(self):<br>        self.function = None<br>        self.d_function = None<br>        self.id = None<br>        self.cached_function = {}<br><br>    def __add__(self, other):<br>        return add(self, other)<br><br>    def __sub__(self, other):<br>        return sub(self, other)<br><br>    def __mul__(self, other):<br>        return mul(self, other)<br><br>    def __truediv__(self, other):<br>        return div(self, other)<br><br>    def __call__(self, *args):<br>        return self.function(*args)<br><br>class Variable:<br>    def __init__(self):<br>        pass<br>        <br>    def __new__(cls, id):<br>        cls.node = Node()<br>        cls.node.id = id<br>        cls.node.function = lambda z: z[id,0] if id &lt; len(z) else 0<br>        cls.node.d_function = lambda j: Constant(1.0) if j == id else Constant(0.0)<br>        return cls.node<br><br>class Constant:<br>    def __init__(self, val):<br>        pass<br>        <br>    def __new__(cls, val):<br>        cls.node = Node()<br>        cls.node.function = lambda z: val<br>        cls.node.d_function = lambda j: Constant(0.0)<br>        return cls.node<br><br>def my_decorator(f):<br>    @wraps(f)<br>    def wrapper(*args, **kwds):<br>        node = Node()<br>        new_args = []<br><br>        for i in range(len(args)):<br>            if i == len(args)-1 and f.__name__ == &#39;grad&#39;:<br>                new_args += [args[i]]<br>            else:<br>                if isinstance(args[i], Node):<br>                    new_args += [args[i]]<br>                else:<br>                    raise Exception(&quot;Invalid input parameter !!!&quot;)<br><br>        evaluate, derive = f(*new_args, **kwds)<br>        <br>        node.function = evaluate<br>        node.d_function = derive<br>        <br>        return node<br>        <br>    return wrapper <br><br>def get_cached_function(x, p):<br>    q = tuple(p.reshape((1, p.shape[0]))[0].tolist())<br>        <br>    if q not in x.cached_function:<br>        x.cached_function[q] = x.function(p) <br>    return x.cached_function[q]<br><br>@my_decorator<br>def cos(x):<br>    def evaluate(p):<br>        z = get_cached_function(x, p)<br>        return math.cos(z)<br>    def derive(j):<br>        return Constant(-1.0)*sin(x)*grad(x,j)<br>        <br>    return evaluate, derive<br><br>@my_decorator<br>def sin(x):<br>    def evaluate(p):<br>        z = get_cached_function(x, p)<br>        return math.sin(z)<br>    def derive(j):<br>        return cos(x)*grad(x,j)<br>        <br>    return evaluate, derive<br><br>@my_decorator<br>def log(x):<br>    def evaluate(p):<br>        z = get_cached_function(x, p)<br>        return math.log(z)<br>    def derive(j):<br>        return (Constant(1.0)/x)*grad(x,j)<br>        <br>    return evaluate, derive<br><br>@my_decorator<br>def exp(x):<br>    def evaluate(p):<br>        z = get_cached_function(x, p)<br>        return math.exp(z)<br>    def derive(j):<br>        return exp(x)*grad(x,j)<br>        <br>    return evaluate, derive<br><br>@my_decorator<br>def pow(x, y):<br>    def evaluate(p):<br>        z = get_cached_function(x, p)<br>        w = get_cached_function(y, p)<br>        return math.pow(z, w)<br>    def derive(j):<br>        return y*pow(x, y-Constant(1.0))*grad(x,j)<br>        <br>    return evaluate, derive<br><br>@my_decorator<br>def add(x, y):<br>    def evaluate(p):<br>        z = get_cached_function(x, p)<br>        w = get_cached_function(y, p)<br>        return z + w<br>    def derive(j):<br>        return grad(x,j)+grad(y,j)<br>        <br>    return evaluate, derive<br><br>@my_decorator<br>def sub(x, y):<br>    def evaluate(p):<br>        z = get_cached_function(x, p)<br>        w = get_cached_function(y, p)<br>        return z - w<br>    def derive(j):<br>        return grad(x,j)-grad(y,j)<br>        <br>    return evaluate, derive<br><br>@my_decorator<br>def mul(x, y):<br>    def evaluate(p):<br>        z = get_cached_function(x, p)<br>        w = get_cached_function(y, p)<br>        return z * w<br>    def derive(j):<br>        return x*grad(y,j) + y*grad(x,j)<br>        <br>    return evaluate, derive<br><br>@my_decorator<br>def div(x, y):<br>    def evaluate(p):<br>        z = get_cached_function(x, p)<br>        w = get_cached_function(y, p)<br>        return z / w<br>    def derive(j):<br>        return Constant(-1.0)*x*pow(grad(y,j), Constant(-2.0)) + pow(y, Constant(-1.0))*grad(x,j)<br>        <br>    return evaluate, derive<br><br>@my_decorator<br>def grad(f,j):<br>    def evaluate(p):<br>        return f.d_function(j).function(p)<br>    def derive(k):<br>        return f.d_function(j).d_function(k)<br>    return evaluate, derive<br><br>if __name__ == &#39;__main__&#39;:<br>    x = np.array([[Variable(0)], [Variable(1)]])<br>    f = cos(pow(x[0,0],Constant(3.0)) + pow(x[1,0],Constant(2.0)) + Constant(2.0)*x[0,0]*x[1,0])<br>    inp = np.array([[0.543], [0.678]])<br><br>    # forward pass to cache the function values<br>    f(inp)<br>    assert grad(grad(f, 1), 0)(inp) == -3.1197960007398184</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/528/1*GMynTHOZM_-A85ScY_GtNw.png" /></figure><p>To first compute the partial derivative w.r.t. y and then w.r.t. x:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*9mffb_7p1JWgRiAIJ4rkAQ.png" /></figure><p>at the point [0.543, 0.678].</p><p>When you compare the results of the gradients computed using automatic differentiation vs. numerical methods as described earlier, we can see that the result from automatic differentiation is much closer to the true value and thus the error is almost 0.</p><p>The updated <a href="https://medium.com/@mecha-mind/implement-l-bfgs-optimization-from-scratch-3e5c909f9079">L-BFGS code</a> (introduced in the previous post of this series) using automatic differentiation instead of explicit gfn() method described earlier.</p><pre>import numpy as np<br>from collections import deque<br><br>def p_grad(f, n):<br>    def eval(x0):<br>        out = []<br>        for i in range(n):<br>            out += [[grad(f, i)(x0)]]<br>    <br>        return np.array(out)<br>    return eval<br><br>def fn(x):<br>    # Compute the optimization function<br>    return pow(Constant(1.0)-x[0,0], Constant(2.0)) + Constant(100.0)*pow((x[1,0]-pow(x[0,0], Constant(2.0))), Constant(2.0))<br><br>def line_search(x, g, d, fnode, gnode, a0=1e-5, e=1e-6):<br>    # Perform line (binary) search to find optimal value of a<br>    left, right = 0.0, 1.0<br>    a, c1, c2 = a0, 1e-4, 0.9<br>    <br>    while left &lt; right:<br>        m = (left + right)/2<br>        <br>        x_new = x - m*d<br>        f_new = fnode(x_new)<br>        g_new = gnode(x_new)<br><br>        # sufficient decrease condition<br>        if f_new-fnode(x) &lt; -c1*m*np.dot(g.T, d):<br>            # curvature condition<br>            if abs(np.dot(d.T, g_new)) &lt; c2*abs(np.dot(d.T, g)):<br>                return m<br>                <br>            a = m<br>            left = m + e<br>        else:<br>            right = m - e<br>            <br>    return a<br>    <br>def optimize(x0, k=10, epsilon=1e-7, max_iter=1e5):<br>    # l-bfgs optimization algorithm<br>    m = len(x0)<br>    v = np.array([[Variable(i)] for i in range(m)])<br><br>    fnode = fn(v)<br>    gnode = p_grad(fnode, m)<br>    <br>    x = x0<br>    f = fnode(x)<br>    g = gnode(x)<br>    <br>    # Store the last k values of y and s vectors<br>    prev_values = deque([(x, g)])<br><br>    while max_iter &gt; 0:<br>        print(f&quot;Current objective value = {f}&quot;)<br>        max_iter -= 1<br>        <br>        prev_x = x<br>        prev_g = g<br><br>        q = g<br>        for i in range(len(prev_values)-1, -1, -1):<br>            y, s = prev_values[i]<br>            r = 1.0/np.dot(y.T, s)<br>            q = q - r*np.dot(y.T, q)*s<br><br>        for i in range(len(prev_values)):<br>            y, s = prev_values[i]<br>            r = 1.0/np.dot(y.T, s)<br>            q = q - r*np.dot(s.T, q)*y<br><br>        p = 0<br>        for i in range(len(prev_values)-1, -1, -1):<br>            w = g<br>            <br>            for j in range(len(prev_values)-1, -1, -1):<br>                if j &gt; i:<br>                    y, s = prev_values[j]<br>                    r = 1.0/np.dot(y.T, s)<br>                    w = w - r*np.dot(y.T, w)*s<br>                else:<br>                    break<br>                    <br>            y, s = prev_values[i]<br>            r = 1.0/np.dot(y.T, s)<br>            w = r*np.dot(s.T, w)*s<br><br>            for j in range(len(prev_values)):<br>                if j &lt; len(prev_values)-1-i:<br>                    y, s = prev_values[j]<br>                    r = 1.0/np.dot(y.T, s)<br>                    w = w - r*np.dot(s.T, w)*y<br>                else:<br>                    break<br>            p += w<br><br>        # Descent direction<br>        d = p+q<br><br>        # Perform line search<br>        a = line_search(x, g, d, fnode, gnode)<br>        <br>        x = x - a*d<br>        f = fnode(x)<br>        g = gnode(x)<br><br>        y = x-prev_x<br>        s = g-prev_g<br>        <br>        prev_values.append((y, s))<br><br>        # Maintain only latest k values<br>        if len(prev_values) &gt; k:<br>            prev_values.popleft()<br><br>        # Exit condition<br>        if np.abs(y).all() &lt; epsilon:<br>            break<br>        <br>    return x<br><br>if __name__ == &#39;__main__&#39;:<br>    x0 = np.array([[0.003], [0.006]])<br>    x = optimize(x0, k=10, epsilon=1e-7)<br>    print(x)</pre><p>Two popular libraries for automatic differentiation are autograd and JAX. I would encourage the reader to try out JAX for the above L-BFGS implementation.</p><p>In the next post we will dive deeper into L-BFGS algorithm for problems involving constraints and understand L-BFGS-B i.e. L-BFGS Bounded, algorithm.</p><p>References:</p><ol><li><a href="https://mblondel.org/teaching/autodiff-2020.pdf">Automatic differentiation (mblondel.org)</a></li><li><a href="https://github.com/HIPS/autograd">HIPS/autograd: Efficiently computes derivatives of numpy code. (github.com)</a></li><li><a href="https://jax.readthedocs.io/en/latest/quickstart.html">Quickstart — JAX documentation</a></li><li><a href="https://ftp.gwdg.de/pub/misc/EMIS/journals/DMJDMV/vol-ismp/52_griewank-andreas-b.pdf">52_griewank-andreas-b.pdf (gwdg.de)</a></li><li><a href="https://jmlr.org/papers/volume18/17-468/17-468.pdf">17–468.pdf (jmlr.org)</a></li><li><a href="https://www.cs.toronto.edu/~rgrosse/courses/csc321_2018/slides/lec10.pdf">CSC321 Lecture 10: Automatic Differentiation (toronto.edu)</a></li></ol><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=3f95260377f5" width="1" height="1" alt=""><hr><p><a href="https://medium.com/gaussian-machine/automatic-differentiation-from-scratch-3f95260377f5">Automatic Differentiation from scratch</a> was originally published in <a href="https://medium.com/gaussian-machine">Gaussian Machine</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[A hate story of Rust and Non Linear Data Structures]]></title>
            <link>https://mecha-mind.medium.com/a-hate-story-of-rust-and-non-linear-data-structures-7da40705c1e9?source=rss-3d860626bbc7------2</link>
            <guid isPermaLink="false">https://medium.com/p/7da40705c1e9</guid>
            <category><![CDATA[rust]]></category>
            <category><![CDATA[data-structures]]></category>
            <category><![CDATA[binary-search-tree]]></category>
            <category><![CDATA[rust-programming-language]]></category>
            <category><![CDATA[avl-tree]]></category>
            <dc:creator><![CDATA[Abhijit Mondal]]></dc:creator>
            <pubDate>Mon, 19 Feb 2024 04:30:48 GMT</pubDate>
            <atom:updated>2024-02-19T04:35:28.010Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*urd7xAuoMnYO_MwcpqVNOA.jpeg" /><figcaption>source: quanta magazine</figcaption></figure><p>Quite recently Rust has gained a lot of popularity as a high level programming language that is <strong>fast and memory safe</strong>. The high performance of Rust primarily stems from the fact that it is a <strong>compiled language similar to C/C++</strong>.</p><p>I have explored the memory management and memory safety aspect of Rust in my previous post. Do give it a read.</p><p><a href="https://medium.com/@mecha-mind/the-time-i-had-mixed-feelings-about-rust-ba0457ae9f31">The time I had mixed feelings about Rust … | by Abhijit Mondal | Feb, 2024 | Medium</a></p><p>In this post, I wanted to show and explain <strong>how I struggled to implement some non-linear data structure operations using Rust especially Trees.</strong></p><p>Here in this post I will show some possible ways by which we can <strong>implement AVL Tree</strong> (insertion operation only) and see what kind of challenges we are going to face as a newbie Rust adopter.</p><p><strong>AVL Tree is a self balanced binary search tree that allows O(logN) worst case time complexity for search, insert and delete operations.</strong></p><p>Although a standard binary search tree also has O(logN) time complexity but it is the average and best case complexity. In the worst case, <strong>a skewed BST has time complexity of O(N) for all these operations</strong>.</p><p>AVL Tree uses self balancing techniques after each insertion and deletion operation in order to ensure that the <strong>height difference between left and right subtree for any node is atmost 1</strong>. This ensures that the height of AVL Tree is O(logN) for N data points. In this post I will only show insertion operations on an AVL Tree.</p><p>Before we can explain why Rust is challenging for an AVL Tree, we need to explain how AVL Tree achieves height re-balancing for these conditions after each insertion.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/774/1*pLamBdterMB2m1Wy_6BCKA.png" /></figure><p>For the 1st case, there can be 2 scenarios after a new node is inserted (note that the AVL Tree before insertion is height balanced).</p><p>Case 1: Insert node 10</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Y6DHEBoj2UnG9bmx0n_lvQ.png" /></figure><p>In the diagram node 80 violates the height balancing property. Will assume that <strong>node 80 is the 1st node in the path from the new node 10 to root node that violates height balancing property</strong>.</p><p>Case 2: Insert node 60</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*wOmpA8xzvWgjrO94fg6LWA.png" /></figure><p>Case 1 is what is called the <strong>left left rotation</strong> and we can rebalance the tree as follows. Here we are assuming that the root node of sub-tree T1 is 40.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*qZ_TRaEBl42d37K3Mn9zbw.png" /></figure><p>Note that if instead of node 40, value of this node was 5, then node 10 would have gone to sub-tree T5 instead of T4. <strong>But even in that case the above rotation is valid</strong>. The main thing is that node 10 is less than node 50 for left left rotation.</p><p>Case 2 is what is called <strong>left right rotation</strong> because node 60 goes to left of node 80 and then to right of node 50. We will assume that the root node of sub-tree T2 is node 55.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Qk1LfdUsH78g9qy3MuP9kA.png" /></figure><p><strong>This left right rotation is a 2 step process, where first we do right rotation on node 50, then we do left rotation on node 55.</strong></p><p>Similar logic is applicable for the 2nd violation condition, when the right sub-tree has height more than 1 than the left sub-tree. In such a case, we do <strong>“right right” rotation</strong> if the new node has value greater than the right child and do <strong>“right left” rotation</strong> if the new node has value smaller than the right child. (I will not go into the details of that approach here).</p><p>Let’s dive into the approaches here:</p><h3>Box</h3><p>This is in continuation of our previous post, where we had used Box smart pointers to implement a BST node. We will continue to use it here and explore some of its advantages and disadvantages.</p><p>Let’s start by defining our AVLTree struct and the Node struct:</p><pre>pub trait MyTrait: PartialOrd + Debug + Copy {}<br><br>impl MyTrait for isize {}<br>impl MyTrait for usize {}<br><br>impl MyTrait for f32 {}<br>impl MyTrait for f64 {}<br><br>impl MyTrait for &amp;str {}<br><br>struct AVLTree&lt;T&gt; where T: PartialOrd {<br>    root_node: Option&lt;Box&lt;Node&lt;T&gt;&gt;&gt;,<br>}<br><br>impl&lt;T: MyTrait&gt; AVLTree&lt;T&gt; {<br>    fn new() -&gt; Self {<br>        Self {<br>            root_node: None,<br>        }<br>    }<br>}<br><br>#[derive(Clone)]<br>struct Node&lt;T&gt; {<br>    val: T,<br>    lt_node: Option&lt;Box&lt;Node&lt;T&gt;&gt;&gt;,<br>    rt_node: Option&lt;Box&lt;Node&lt;T&gt;&gt;&gt;,<br>    height: isize<br>}<br><br>impl&lt;T: MyTrait&gt; Node&lt;T&gt; {<br>    fn new(&amp;val:&amp;T) -&gt; Self {<br>        Self {<br>            val,<br>            lt_node: None,<br>            rt_node: None,<br>            height: 1,<br>        }<br>    }<br>}</pre><p>The Node of our AVLTree is assumed to hold the following data types in Rust: <strong>isize, usize, f32, f64 and &amp;str</strong>. We can always extend this list but for demonstration purposes, we will keep it small.</p><p>Next we define some helper methods that we will use for height calculations. These methods are required during re-balancing stage.</p><pre>impl&lt;T: MyTrait&gt; AVLTree&lt;T&gt; {<br>    fn get_lt_rt_heights(&amp;self, root_node:&amp;Option&lt;Box&lt;Node&lt;T&gt;&gt;&gt;) -&gt; (isize, isize) {<br>        // returns tuple of left and right subtree heights<br> <br>        let mut lheight:isize = 0;<br>        let mut rheight:isize = 0;<br><br>        match root_node {<br>            Some(root) =&gt; {<br>                let lt_node = &amp;root.lt_node;<br>                let rt_node = &amp;root.rt_node;<br><br>                match lt_node {<br>                    Some(x) =&gt; {<br>                        lheight = x.height;<br>                    }<br>                    None =&gt; {}<br>                }<br><br>                match rt_node {<br>                    Some(x) =&gt; {<br>                        rheight = x.height;<br>                    }<br>                    None =&gt; {}<br>                }<br>            }<br>            None =&gt; {}<br>        }<br><br>        return (lheight, rheight);<br>    }<br>}<br><br>impl&lt;T: MyTrait&gt; AVLTree&lt;T&gt; {<br>    fn height_diff(&amp;self, root_node:&amp;Option&lt;Box&lt;Node&lt;T&gt;&gt;&gt;) -&gt; isize {<br>        // returns difference b/w left subtree height and right subtree height<br>        let lr_heights = self.get_lt_rt_heights(&amp;root_node);<br>        return lr_heights.0 - lr_heights.1;<br>    }<br>}<br><br>impl&lt;T: MyTrait&gt; AVLTree&lt;T&gt; {<br>    fn set_height(&amp;mut self, root_node:&amp;mut Option&lt;Box&lt;Node&lt;T&gt;&gt;&gt;) {<br>        // calculate the height of a tree node<br>        let lr_heights = self.get_lt_rt_heights(&amp;root_node);<br><br>        match root_node {<br>            Some(root) =&gt; {<br>                root.height = 1 + max(lr_heights.0, lr_heights.1);<br>            }<br>            None =&gt; {}<br>        }<br>    }<br>}</pre><p>Note that in the 1st two methods above, we are passing root_node as an immutable reference and in the 3rd method i.e. set_height as a mutable reference.</p><p>If we do not pass as a reference i.e. borrow root_node, then <strong>root_node will move into the function and its scope will end with the function</strong>. The reason for passing as mutable reference is that we are updating the root_node struct properties in-place i.e. height of root_node.</p><p>Everytime we define a function that takes as input the root_node, we need to <strong>either pass it as a reference</strong> (if we are doing in-place modifications to the heap object) or <strong>move it inside the function but create a clone object in heap and return the clone object</strong>.</p><p>Something similar to below:</p><pre>impl&lt;T: MyTrait&gt; AVLTree&lt;T&gt; {<br>    fn set_height(&amp;mut self, root_node:Option&lt;Box&lt;Node&lt;T&gt;&gt;&gt;)-&gt;Option&lt;Box&lt;Node&lt;T&gt;&gt;&gt; {<br>        // moving root_node into set_height method<br>        let mut root_clone = root_node.clone();<br>        let lr_heights = self.get_lt_rt_heights(&amp;root_clone);<br><br>        match root_clone {<br>            Some(ref mut root) =&gt; {<br>                root.height = 1 + max(lr_heights.0, lr_heights.1);<br>            }<br>            None =&gt; {}<br>        }<br><br>        return root_clone;<br>    }<br>}</pre><p>But cloning a heap object has memory implications as with Box smart pointers, <strong>Box pointer owns the data</strong> thus when we clone the object in memory, <strong>the value is also copied</strong>.</p><p>But in the above code, Rust compiler detects that the original root_node that moved into the function was never used again, thus it deallocates the heap memory assigned to original root_node. Thus only a single copy of root_node exists at any point in time in the above code.</p><p>But it is not always as simple as this. One can track the peak memory usage using “heaptrack” utility.</p><p>Compile and run the program <strong>avl_tree.rs</strong> with heaptrack:</p><pre>RUSTFLAGS=-g cargo build --release<br>heaptrack target/release/avl_tree</pre><p>If you have heaptrack_gui installed, you can see the graph of memory usage overtime. The <strong>peak heap memory</strong> usage gives the maximum heap memory used by the program and <strong>peak RSS memory</strong> is all types of memory used by the program including stack, heap etc.</p><p>Now let’s look at the left rotation function. This function takes as input node X and updates the sub-tree rooted at X as follows:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*H2gME8bwPWP68Aasr4hhbQ.png" /></figure><p>The code is as follows:</p><pre>impl&lt;T: MyTrait&gt; AVLTree&lt;T&gt; {<br>    fn left_rotate(&amp;mut self, root_node:&amp;mut Option&lt;Box&lt;Node&lt;T&gt;&gt;&gt;) {<br>        match root_node {<br>            Some(node) =&gt; {<br>                let lt_node = &amp;mut node.lt_node;<br>                let lt_node_clone = &amp;mut lt_node.clone();<br><br>                match lt_node {<br>                    Some(x) =&gt; {<br>                        let lt_rt_node = &amp;x.rt_node;<br>                        node.lt_node = lt_rt_node.clone();<br>                    }<br>                    None =&gt; {}<br>                }<br><br>                self.set_height(root_node);<br><br>                match lt_node_clone {<br>                    Some(x) =&gt; {<br>                        x.rt_node = root_node.clone();<br>                    }<br>                    None =&gt; {}<br>                }<br><br>                *root_node = lt_node_clone.clone();<br>                self.set_height(root_node);<br>            }<br>            None =&gt; {}<br>        }<br>    }<br>}</pre><p>You would notice that we are doing a bunch clone() operations on the Node objects. <strong>Cloning is required because we are only borrowing the Node objects</strong> and we <strong>cannot move out</strong> by dereferencing inside the function because the <strong>root_node</strong> we are passing to the function <strong>is borrowed</strong>.</p><p>Simply put, if we are <strong>borrowing an object</strong> inside a function and the object is used after function scope ends, we <strong>cannot move out</strong> (or dereference or change ownership) of that object or any other object referenced by the object, inside the function.</p><p>Note that once we connect X to subtree T2, we need to recalculate the height of X. Height of subtree T2 is not affected. Next we recalculate height of Y. Also we are re-assigning the value inside root_node to Y.</p><p>Similarly, we can define the right rotation method:</p><pre>impl&lt;T: MyTrait&gt; AVLTree&lt;T&gt; {<br>    fn right_rotate(&amp;mut self, root_node:&amp;mut Option&lt;Box&lt;Node&lt;T&gt;&gt;&gt;) {<br>        match root_node {<br>            Some(node) =&gt; {<br>                let rt_node = &amp;mut node.rt_node;<br>                let rt_node_clone = &amp;mut rt_node.clone();<br><br>                match rt_node {<br>                    Some(x) =&gt; {<br>                        let rt_lt_node = &amp;x.lt_node;<br>                        node.rt_node = rt_lt_node.clone();<br>                    }<br>                    None =&gt; {}<br>                }<br><br>                self.set_height(root_node);<br><br>                match rt_node_clone {<br>                    Some(x) =&gt; {<br>                        x.lt_node = root_node.clone();<br>                    }<br>                    None =&gt; {}<br>                }<br><br>                *root_node = rt_node_clone.clone();<br>                self.set_height(root_node);<br>            }<br>            None =&gt; {}<br>        }<br>    }<br>}</pre><p>This is reverse of the left rotation.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*xAIM553KlcC_MPX7zgjzSg.png" /></figure><p>Finally we define the insert method:</p><pre>impl&lt;T: MyTrait&gt; AVLTree&lt;T&gt; {<br>    fn insert(&amp;mut self, root_node:&amp;mut Option&lt;Box&lt;Node&lt;T&gt;&gt;&gt;, &amp;val:&amp;T) {<br>        let mut hdiff:isize = 0;<br><br>        match root_node {<br>            Some(node) =&gt; {<br><br>                if val &lt;= node.val {<br>                    let lt_node = &amp;mut node.lt_node;<br>                    match lt_node {<br>                        Some(_x) =&gt; {<br>                            self.insert(lt_node, &amp;val);<br>                        }<br>                        None =&gt; {<br>                            let new_node:Node&lt;T&gt; = Node::new(&amp;val);<br>                            node.lt_node = Some(Box::new(new_node));<br>                        }<br>                    }<br>                }<br><br>                else {<br>                    let rt_node = &amp;mut node.rt_node;<br>                    match rt_node {<br>                        Some(_x) =&gt; {<br>                            self.insert(rt_node, &amp;val);<br>                        }<br>                        None =&gt; {<br>                            let new_node:Node&lt;T&gt; = Node::new(&amp;val);<br>                            node.rt_node = Some(Box::new(new_node));<br>                        }<br>                    }<br>                }<br><br>                self.set_height(root_node);<br>                hdiff = self.height_diff(root_node);<br>            }<br><br>            None =&gt; {<br>                let new_node:Node&lt;T&gt; = Node::new(&amp;val);<br>                *root_node = Some(Box::new(new_node));<br>            }<br>        }<br><br>        if hdiff &gt; 1 {<br>            match root_node {<br>                Some(node) =&gt; {<br>                    let lt_node = &amp;mut node.lt_node;<br>                    match lt_node {<br>                        Some(x) =&gt; {<br>                            if val &lt;= x.val {<br>                                self.left_rotate(root_node);<br>                            }<br>                            else if val &gt; x.val {<br>                                self.right_rotate(lt_node);<br>                                self.left_rotate(root_node);<br>                            }<br>                        }<br>                        None =&gt; {}<br>                    }<br>                }<br>                None =&gt; {}<br>            }<br>        }<br><br>        else if hdiff &lt; -1 {<br>            match root_node {<br>                Some(node) =&gt; {<br>                    let rt_node = &amp;mut node.rt_node;<br>                    match rt_node {<br>                        Some(x) =&gt; {<br>                            if val &lt;= x.val {<br>                                self.left_rotate(rt_node);<br>                                self.right_rotate(root_node);<br>                            }<br>                            else if val &gt; x.val {<br>                                self.right_rotate(root_node);<br>                            }<br>                        }<br>                        None =&gt; {}<br>                    }<br>                }<br>                None =&gt; {}<br>            }<br>        }<br>    }<br>}</pre><p>Lets also define the main method, which creates a vector of 1 million integers from 1 to 1 million, shuffles them and inserts one by one into the tree.</p><pre>fn main() {<br>    let mut avltree:AVLTree&lt;usize&gt; = AVLTree::new();<br>    let mut root:Option&lt;Box&lt;Node&lt;usize&gt;&gt;&gt; = None;<br><br>    let mut vec: Vec&lt;usize&gt; = (1..1000000).collect();<br>    vec.shuffle(&amp;mut thread_rng());<br><br>    for v in vec.iter() {<br>        avltree.insert(&amp;mut root, &amp;v);<br>    }<br>}</pre><p>We can track how much heap memory and total memory used by the program using the heaptrack program.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*WKT4yaC8F8gl7uXsMI5dSw.png" /></figure><p>We can see that the maximum heap memory used at any point in time is around 62 MB. This will vary since the inputs are shuffled.</p><p>If we look at the heaptrack output for lines within the program that consumes that largest heap memory, we can see that all the clone() operations within the left and right rotations take up the largest heap memory.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*nH5BOmHDTfMfiuGGDESjIw.png" /></figure><p>If you observe carefully, for the left rotation, <strong>cloning node Y takes roughly twice the memory of cloning X</strong> simply because <strong>number of nodes in Y is roughly twice that in X</strong>.</p><p>Similarly, <strong>cloning X takes roughly twice memory than T2</strong>. For each unit increase in height, the memory consumption increases by 2x.</p><h3>HashMap</h3><p>In the next implementation, instead of using Box smart pointers for left and right subtrees of a Node, we are going to assign ids to Node and maintain a separate id to Node HashMap.</p><p>The usefulness of this approach is that when we are cloning the Node objects, instead of the (almost) <strong>entire tree being deep copied</strong> in heap memory, we are just <strong>copying individual Node objects</strong> with id fields.</p><pre>struct AVLTree&lt;T&gt; where T: PartialOrd {<br>    auto_inc_id: usize,<br>    id_to_node_map: HashMap&lt;usize, Node&lt;T&gt;&gt;,<br>}<br><br>impl&lt;T: MyTrait&gt; AVLTree&lt;T&gt; {<br>    fn new() -&gt; Self {<br>        Self {<br>            auto_inc_id: 0,<br>            id_to_node_map: HashMap::new(),<br>        }<br>    }<br>}<br><br>#[derive(Clone)]<br>struct Node&lt;T&gt; {<br>    val: T,<br>    lt_node_id: Option&lt;usize&gt;,<br>    rt_node_id: Option&lt;usize&gt;,<br>    height: isize<br>}<br><br>impl&lt;T: MyTrait&gt; Node&lt;T&gt; {<br>    fn new(&amp;val:&amp;T) -&gt; Self {<br>        Self {<br>            val,<br>            lt_node_id: None,<br>            rt_node_id: None,<br>            height: 1,<br>        }<br>    }<br>}</pre><p>Note that we are using <strong>HashMap instead of a Vec</strong> in Rust because if we are implementing deletion, then it is <strong>efficient to delete from a HashMap</strong> rather than a Vector.</p><p>The id field is of type usize.</p><p>I am not going to show all the codes for HashMap implementation here but would recommend going through it in the below repo.</p><p><a href="https://github.com/funktor/rust-algorithms/blob/main/src/bin/avl_tree_hmap.rs">rust-algorithms/src/bin/avl_tree_hmap.rs at main · funktor/rust-algorithms (github.com)</a></p><p>An interesting observation from the heaptrack output is that the peak heap memory with HashMap is quite high as compared to Box approach above. Roughly 2–3 times the memory usage.</p><p>On deep diving we can see that HashMap is the culprit here as it requires additional data structure to hold the key-value pairs and the hashes.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*aj_2gKwKaCCJiJuxTSRiIQ.png" /></figure><p>As you can see that whenever a key-value pair is inserted into the HashMap, there is a possibility that the Hash Table will be resized depending on the load factor and often when they are resized, there are lots of wasted memory in the Hash Table.</p><h3>Rc&lt;RefCell&gt;</h3><p>In the next approach, instead of using Box smart pointers, I had used another type of smart pointers <strong>Rc and RefCell</strong>. Some important differences between Box and Rc&lt;RefCell&gt; are as follows:</p><ol><li>In <strong>Box smart pointers</strong> the data behind the smart pointer has a <strong>single owner</strong>. Thus when you <strong>clone the data</strong>, another <strong>copy of that data</strong> is made in the heap memory as we saw above for left and right rotations.</li><li>In <strong>Rc</strong>, the data behind the smart pointer <strong>can have multiple owners</strong>. But <strong>Rc without RefCell allows only immutable access</strong> i.e. even if there are multiple owners of the data, they cannot mutate the values. Rc represents Reference Counting. Thus whenever a clone is made, the <strong>reference count of the object is incremented by 1</strong>, but not copied in heap memory.</li><li>Using RefCell along with Rc allows <strong>multiple owners to mutate the data</strong> behind the smart pointer. But unlike Box, where the ownership and borrow is checked at compile time, in Rc&lt;RefCell&gt; the borrow is checked at runtime. Thus you will not see “much” compiler complains but if it is the case that multiple owners are trying to mutate the data at runtime, the rust program will panic.</li></ol><p>Here is the data structure definition:</p><pre>struct AVLTree&lt;T&gt; where T: PartialOrd {<br>    root_node: Option&lt;Rc&lt;RefCell&lt;Node&lt;T&gt;&gt;&gt;&gt;,<br>}<br><br>impl&lt;T: MyTrait&gt; AVLTree&lt;T&gt; {<br>    fn new() -&gt; Self {<br>        Self {<br>            root_node: None,<br>        }<br>    }<br>}<br><br>#[derive(Clone)]<br>struct Node&lt;T&gt; {<br>    val: T,<br>    lt_node: Option&lt;Rc&lt;RefCell&lt;Node&lt;T&gt;&gt;&gt;&gt;,<br>    rt_node: Option&lt;Rc&lt;RefCell&lt;Node&lt;T&gt;&gt;&gt;&gt;,<br>    height: isize<br>}<br><br>impl&lt;T: MyTrait&gt; Node&lt;T&gt; {<br>    fn new(&amp;val:&amp;T) -&gt; Self {<br>        Self {<br>            val,<br>            lt_node: None,<br>            rt_node: None,<br>            height: 1,<br>        }<br>    }<br>}</pre><p>The methods to compute the heights of left and right subtrees and set height of self.</p><pre>impl&lt;T: MyTrait&gt; AVLTree&lt;T&gt; {<br>    fn get_lt_rt_heights(&amp;self, root_borrow:&amp;RefMut&lt;&#39;_, Node&lt;T&gt;&gt;) -&gt; (isize, isize) {<br>        let mut lheight:isize = 0;<br>        let mut rheight:isize = 0;<br><br>        let lt_node = &amp;root_borrow.lt_node;<br>        let rt_node = &amp;root_borrow.rt_node;<br><br>        match lt_node {<br>            Some(x) =&gt; {<br>                lheight = x.borrow().height;<br>            }<br>            None =&gt; {}<br>        }<br><br>        match rt_node {<br>            Some(x) =&gt; {<br>                rheight = x.borrow().height;<br>            }<br>            None =&gt; {}<br>        }<br><br>        return (lheight, rheight);<br>    }<br>}<br><br>impl&lt;T: MyTrait&gt; AVLTree&lt;T&gt; {<br>    fn height_diff(&amp;self, root_borrow:&amp;RefMut&lt;&#39;_, Node&lt;T&gt;&gt;) -&gt; isize {<br>        let lr_heights = self.get_lt_rt_heights(&amp;root_borrow);<br>        return lr_heights.0 - lr_heights.1;<br>    }<br>}<br><br>impl&lt;T: MyTrait&gt; AVLTree&lt;T&gt; {<br>    fn set_height(&amp;self, root_borrow:&amp;mut RefMut&lt;&#39;_, Node&lt;T&gt;&gt;) {<br>        let lr_heights = self.get_lt_rt_heights(&amp;root_borrow);<br>        root_borrow.height = 1 + max(lr_heights.0, lr_heights.1);<br>    }<br>}</pre><p>Note that instead of passing a mutable reference to the root_node unlike in the Box method, we are <strong>passing a mutable reference to the borrowed object</strong>.</p><p>This is because when you borrow an object mutably, then you <strong>cannot borrow that object again mutably or immutably</strong> and with Rc&lt;RefCell&gt;, anytime you have to modify the struct, you need to call <strong>borrow_mut()</strong> on the object.</p><p><strong>Thus it is only possible to call borrow_mut() once on the object which in our example will not work.</strong></p><p>The other pattern is <strong>get the RefMut object by calling borrow_mut() once and pass around the RefMut object.</strong></p><p>Similarly, the left rotation and right rotation methods are as follows:</p><pre>impl&lt;T: MyTrait&gt; AVLTree&lt;T&gt; {<br>    fn left_rotate(&amp;mut self, root_borrow:&amp;mut RefMut&lt;&#39;_, Node&lt;T&gt;&gt;)-&gt;Option&lt;Rc&lt;RefCell&lt;Node&lt;T&gt;&gt;&gt;&gt; {<br>        let root_clone = root_borrow.clone();<br>        let lt_node = &amp;root_clone.lt_node;<br><br>        match lt_node {<br>            Some(x) =&gt; {<br>                let lt_rt_node = &amp;x.borrow().rt_node;<br>                match lt_rt_node {<br>                    Some(y) =&gt; {<br>                        root_borrow.lt_node = Some(y.clone());<br>                    }<br>                    None =&gt; {<br>                        root_borrow.lt_node = None;<br>                    }<br>                }<br>            }<br>            None =&gt; {}<br>        }<br><br>        self.set_height(root_borrow);<br><br>        match lt_node {<br>            Some(ref x) =&gt; {<br>                let xborrow = &amp;mut x.borrow_mut();<br>                xborrow.rt_node = Some(Rc::new(RefCell::new(root_borrow.clone())));<br>                self.set_height(xborrow);<br>            }<br>            None =&gt; {}<br>        }<br><br>        return lt_node.clone();<br><br>    }<br>}<br><br>impl&lt;T: MyTrait&gt; AVLTree&lt;T&gt; {<br>    fn right_rotate(&amp;mut self, root_borrow:&amp;mut RefMut&lt;&#39;_, Node&lt;T&gt;&gt;)-&gt;Option&lt;Rc&lt;RefCell&lt;Node&lt;T&gt;&gt;&gt;&gt; {<br>        let root_clone = root_borrow.clone();<br>        let rt_node = &amp;root_clone.rt_node;<br><br>        match rt_node {<br>            Some(x) =&gt; {<br>                let rt_lt_node = &amp;x.borrow().lt_node;<br>                match rt_lt_node {<br>                    Some(y) =&gt; {<br>                        root_borrow.rt_node = Some(y.clone());<br>                    }<br>                    None =&gt; {<br>                        root_borrow.rt_node = None;<br>                    }<br>                }<br>            }<br>            None =&gt; {}<br>        }<br><br>        self.set_height(root_borrow);<br><br>        match rt_node {<br>            Some(ref x) =&gt; {<br>                let xborrow = &amp;mut x.borrow_mut();<br>                xborrow.lt_node = Some(Rc::new(RefCell::new(root_borrow.clone())));<br>                self.set_height(xborrow);<br>            }<br>            None =&gt; {}<br>        }<br><br>        return rt_node.clone();<br><br>    }<br>}</pre><p>And finally the insert method:</p><pre>impl&lt;T: MyTrait&gt; AVLTree&lt;T&gt; {<br>    fn insert(&amp;mut self, root_node:&amp;mut Option&lt;Rc&lt;RefCell&lt;Node&lt;T&gt;&gt;&gt;&gt;, &amp;val:&amp;T)-&gt;Option&lt;Rc&lt;RefCell&lt;Node&lt;T&gt;&gt;&gt;&gt; {        <br>        let mut output:Option&lt;Rc&lt;RefCell&lt;Node&lt;T&gt;&gt;&gt;&gt; = root_node.clone();<br><br>        match root_node {<br>            Some(ref node) =&gt; {<br>                let mut node_borrow = node.borrow_mut();<br><br>                if val &lt;= node_borrow.val {<br>                    let lt_node = &amp;mut node_borrow.lt_node;<br>                    let new_lt:Option&lt;Rc&lt;RefCell&lt;Node&lt;T&gt;&gt;&gt;&gt;;<br><br>                    match lt_node {<br>                        Some(_x) =&gt; {<br>                            new_lt = self.insert(lt_node, &amp;val);<br>                        }<br>                        None =&gt; {<br>                            let new_node:Node&lt;T&gt; = Node::new(&amp;val);<br>                            new_lt = Some(Rc::new(RefCell::new(new_node)));<br>                        }<br>                    }<br><br>                    node_borrow.lt_node = new_lt;<br>                }<br>                else {<br>                    let rt_node = &amp;mut node_borrow.rt_node;<br>                    let new_rt:Option&lt;Rc&lt;RefCell&lt;Node&lt;T&gt;&gt;&gt;&gt;;<br><br>                    match rt_node {<br>                        Some(_x) =&gt; {<br>                            new_rt = self.insert(rt_node, &amp;val);<br>                        }<br>                        None =&gt; {<br>                            let new_node:Node&lt;T&gt; = Node::new(&amp;val);<br>                            new_rt = Some(Rc::new(RefCell::new(new_node)));<br>                        }<br>                    }<br>                    node_borrow.rt_node = new_rt;<br>                }<br>            }<br>            None =&gt; {<br>                let new_node:Node&lt;T&gt; = Node::new(&amp;val);<br>                output = Some(Rc::new(RefCell::new(new_node)));<br>            }<br>        }<br><br>        let mut hdiff:isize = 0;<br><br>        match root_node {<br>            Some(ref node) =&gt; {<br>                let mut node_borrow = node.borrow_mut();<br><br>                self.set_height(&amp;mut node_borrow);<br>                hdiff = self.height_diff(&amp;node_borrow);<br>            }<br>            None =&gt; {}<br>        }<br><br>        match root_node {<br>            Some(ref node) =&gt; {<br>                let mut node_borrow = node.borrow_mut();<br>                <br>                if hdiff &gt; 1 {<br>                    let lt_node = &amp;mut node_borrow.lt_node;<br><br>                    match lt_node {<br>                        Some(ref x) =&gt; {<br>                            if val &lt;= x.borrow().val {<br>                                output = self.left_rotate(&amp;mut node_borrow);<br>                            }<br>                            else if val &gt; x.borrow().val {<br>                                let new_lt = self.right_rotate(&amp;mut x.borrow_mut());<br>                                node_borrow.lt_node = new_lt;<br>                                output = self.left_rotate(&amp;mut node_borrow);<br>                            }<br>                        }<br>                        None =&gt; {}<br>                    }<br>                }<br>        <br>                else if hdiff &lt; -1 {<br>                    let rt_node = &amp;mut node_borrow.rt_node;<br><br>                    match rt_node {<br>                        Some(ref x) =&gt; {<br>                            if val &lt;= x.borrow().val {<br>                                let new_rt = self.left_rotate(&amp;mut x.borrow_mut());<br>                                node_borrow.rt_node = new_rt;<br>                                output = self.right_rotate(&amp;mut node_borrow);<br>                            }<br>                            else if val &gt; x.borrow().val {<br>                                output = self.right_rotate(&amp;mut node_borrow);<br>                            }<br>                        }<br>                        None =&gt; {}<br>                    }<br>                }<br>            }<br>            None =&gt; {}<br>        }<br><br>        return output.clone();<br>    }<br>}</pre><p>Note that we call borrow_mut() once before using the returned RefMut object in the left and right rotation function calls.</p><p>Now lets look at the heap memory usage and how the usage per line looks like using heaptrack.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*TvqMfCtVdKoTWtVANYT-QA.png" /></figure><p>Peak heap memory usage is somewhere around 64 MB.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*GIy_6UQgwh21sGlR-ToPqw.png" /></figure><p>The line in the left and right rotation functions, which has the most heap memory usages are:</p><pre>xborrow.rt_node = Some(Rc::new(RefCell::new(root_borrow.clone())));<br><br>xborrow.lt_node = Some(Rc::new(RefCell::new(root_borrow.clone())));</pre><p>Also the most memory usage lines in the insert function are as follows:</p><pre>new_lt = Some(Rc::new(RefCell::new(new_node)));<br><br>new_rt = Some(Rc::new(RefCell::new(new_node)));</pre><p>We can clearly see the pattern here. Whenever we are <strong>creating a new Rc&lt;RefCell&gt; object in the heap</strong>, the memory usage is high. This is because when we are calling <strong>root_borrow.clone()</strong> in the left and right rotation functions, we are <strong>creating copies in heap memory</strong>.</p><p>Note that calling .clone() on existing Rc&lt;RefCell&gt; objects will not create copies but calling clone on the RefMut object will.</p><p>Personally, I feel Box method is much “cleaner” and “safer” way simply because I get to know all the memory issues at compile time instead of getting “panciky” at runtime where the error messages are not always very helpful.</p><p>There could be better way to implement these functions which I have not yet explored or not aware of. But the idea I wanted to demonstrate here is that there are multiple different ways to implement a non-linear data structure in Rust each with its own set of challenges. There are lots of struggle involved to get it right.</p><p>But the good part is that we can analyze the memory usages with heaptrack tool to understand how each method behaves at runtime and thus one can find ways to optimize them.</p><h4>References</h4><ol><li><a href="https://flakm.github.io/posts/heap_allocation/">Profiling heap allocation in rust | FlakM blog</a></li><li><a href="https://milianw.de/blog/heaptrack-a-heap-memory-profiler-for-linux.html">Heaptrack — A Heap Memory Profiler for Linux — Milian Wolff</a></li><li><a href="https://nnethercote.github.io/perf-book/title-page.html">Title Page — The Rust Performance Book (nnethercote.github.io)</a></li><li><a href="https://doc.rust-lang.org/book/title-page.html">The Rust Programming Language — The Rust Programming Language (rust-lang.org)</a></li><li><a href="https://doc.rust-lang.org/book/ch15-04-rc.html">Rc&lt;T&gt;, the Reference Counted Smart Pointer — The Rust Programming Language (rust-lang.org)</a></li><li><a href="https://doc.rust-lang.org/book/ch15-05-interior-mutability.html">RefCell&lt;T&gt; and the Interior Mutability Pattern — The Rust Programming Language (rust-lang.org)</a></li></ol><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=7da40705c1e9" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The time I had mixed feelings about Rust …]]></title>
            <link>https://mecha-mind.medium.com/the-time-i-had-mixed-feelings-about-rust-ba0457ae9f31?source=rss-3d860626bbc7------2</link>
            <guid isPermaLink="false">https://medium.com/p/ba0457ae9f31</guid>
            <category><![CDATA[rust]]></category>
            <category><![CDATA[algorithms]]></category>
            <category><![CDATA[python]]></category>
            <category><![CDATA[memory-management]]></category>
            <category><![CDATA[binary-search-tree]]></category>
            <dc:creator><![CDATA[Abhijit Mondal]]></dc:creator>
            <pubDate>Sat, 10 Feb 2024 13:18:46 GMT</pubDate>
            <atom:updated>2024-02-11T12:01:08.015Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*GedS462lzYYcMjmHAgqsIg.jpeg" /><figcaption>source: quanta magazine</figcaption></figure><h3>The time I had mixed feelings about Rust …</h3><p>Due to Rust’s recent rise in popularity among software engineers around the world, I got interested in learning about Rust and how it would help me and my team write better production grade codes.</p><p><strong>Note: I come from a background with extensive experience in Python and some bit of C/C++ and Java.</strong></p><p>After reading some materials on Rust and its internal workings, here are few points that clearly stood out as to how and why Rust is different.</p><ol><li>The most important aspect of Rust is how it manages memory. Instead of deploying a garbage collector at runtime it checks for <strong>single ownership and borrowing</strong> at compile time.</li><li>Unlike Python, Rust is a <strong>compiled programming language</strong> similar to C/C++.</li><li>Its not the responsibility of the OS or the Rust runtime to clean up unused heap memory or optimize memory usage but the onus is on the <strong>developer to take all the pain of writing production grade codes</strong>.</li></ol><p>Let me elaborate a bit on point number 1.</p><p>There are 2 ways by which an OS allocates functions and variables in the computer’s memory —<strong> Stack and Heap</strong>.</p><p><a href="https://courses.engr.illinois.edu/cs225/fa2022/resources/stack-heap/">CS 225 | Stack and Heap Memory (illinois.edu)</a></p><p><strong>Functions </strong>(arguments etc.) and <strong>variables </strong>with primitive data types are stored on a <strong>stack data structure</strong> in the memory. The sizes of these objects are predefined prior to program run.</p><p>For e.g. in C++, “int” takes up 4 bytes, “long” 8 bytes and so on. The objects in the stack are deallocated using a LIFO strategy.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/679/1*1vae3qxRlf0gr8TqKHkm6w.png" /><figcaption>stack vs. heap allocation</figcaption></figure><p>Hence <strong>stack deallocation do not require explicit garbage collection</strong> or memory management as when the top object in the stack goes out of scope, it is deallocated and the memory is freed.</p><p>Objects having <strong>dynamic sizes</strong> i.e. the size can increase or decrease during program run such as a <strong>vector in C++, list in Python</strong> etc. are allocated in the <strong>Heap instead of Stack</strong></p><p>(Reference to the actual object stored in Heap might be stored in the Stack).</p><p>Objects in the <strong>Heap are not automatically deallocated</strong> and require either explicit way such as using “delete” keyword in C/C++ or “del” in Python, or the <strong>garbage collector will clean them up after program completion</strong>.</p><p><a href="https://courses.cs.washington.edu/courses/csep521/07wi/prj/rick.pdf">Microsoft Word — CSEP521 Project — Garbage Collection Algorithms.doc (washington.edu)</a></p><p><a href="https://newrelic.com/blog/best-practices/java-garbage-collection">Java garbage collection: What is it and how does it work? | New Relic</a></p><p><a href="https://hollycummins.com/garbage-collection-algorithms/">Garbage collection algorithms — Holly Cummins</a></p><p><strong>C/C++ do not use garbage collection</strong> to clean up the Heap memory. Python and JAVA on the other hand uses garbage collection algorithms to clean up Heap memory.</p><p>Thus C/C++ can have <strong>memory leakage issues</strong> if an object that was allocated in the Heap is not explicitly deallocated.</p><p>Let’s think about this program in CPython:</p><pre>import ctypes<br><br># The list is stored in heap space<br># The references x and y will be stored in stack<br>x = [1,2,3,4]<br>y = x<br><br># x and y points to the same memory location in the heap<br>x_id = id(x)<br>y_id = id(y)<br><br># They will print same id<br>print(f&quot;x_id={x_id}, y_id={y_id}&quot;)<br><br># changing either x or y will be reflected in both<br>y[2] = 5<br>x[0] = 7<br><br>print(f&quot;x={x}, y={y}&quot;)<br><br># get the refernce count of the address in memory holding the list<br># It will be 2 (because x and y are both pointing to it)<br>print(ctypes.c_long.from_address(x_id).value)<br><br># remove reference y<br>del y<br><br># reference count will now be 1 <br># (only x is pointing to the same memory location in heap)<br>print(ctypes.c_long.from_address(x_id).value)</pre><p>After the above program ends, the cpython garbage collector will clean up the remaining reference i.e. reference x to the heap memory location and also free the heap memory.</p><p>What if we remove both the references x and y in the code:</p><pre># remove last reference x<br>del x<br><br># check the contents of the memory address x_id<br>print(ctypes.cast(x_id, ctypes.py_object).value)</pre><p><strong>If the reference count of an object in heap space reaches 0, the object is deallocated and memory is freed.</strong></p><p>But there could be certain scenarios where there are <strong>cyclic references</strong> and in that case <strong>reference count will never reach 0</strong>. Only option to the free the memory is by using a Garbage Collector.</p><p>An example of cyclic reference count in CPython.</p><pre>class Node:<br>  def __init__(self):<br>    self.next = None<br><br>x = Node()<br>y = Node()<br><br>x.next = y<br>y.next = x<br><br>x_id = id(x)<br>y_id = id(y)<br><br>print(f&quot;x_id={x_id}, y_id={y_id}&quot;)<br><br># reference counts will be 2 for both x and y<br>print(ctypes.c_long.from_address(x_id).value)<br>print(ctypes.c_long.from_address(y_id).value)</pre><p>Some disadvantages with this kind of memory manangement are as follows:</p><ol><li>What if someone <strong>intended to do a cloning of the list</strong> instead of referencing ? <br>Any changes made to a one reference also updates all the other references.</li><li>If there are <strong>many objects with cyclic references</strong> as seen above, the heap space are not freed at runtime even if some of these objects are not used anymore.</li><li>Due to multiple references to the same object in heap space, the objects are not freed until number of references reduces to 0. Let’s take a simple example as below.</li></ol><pre>def sum_of_max_per_list(input_list:list[list]):<br>    input_list_id = id(input_list)<br>    n = len(input_list)<br>    <br>    outputs = [0]*n<br>    for i in range(n):<br>        outputs[i] = max(input_list[i])<br><br>    # reference count is 3<br>    # one for initization outside this method, one for function argument<br>    # and one for function stack<br>    print(ctypes.c_long.from_address(input_list_id).value)<br><br>    # some memory intensive workloads<br>    s = sum(outputs)<br>    <br>    return []<br><br>input_list = [[10,1,12,4],[5,87,13,65],[10,11,12,13,14],[46,1,32,90,87]]<br>s = max_per_list(input_list)</pre><p>Note that after we find the maximum element per list, there is no further requirement of “input_list” variable. If afterwards we are doing some memory intensive task with the output, it could lead to OOM errors in Python because the memory pointed to by input_list is not freed.</p><p>A better approach would be:</p><pre>def max_per_list(input_list:list[list]):<br>    n = len(input_list)<br>    <br>    outputs = [0]*n<br>    for i in range(n):<br>        outputs[i] = max(input_list[i])<br>    <br>    return outputs<br><br>input_list = [[10,1,12,4],[5,87,13,65],[10,11,12,13,14],[46,1,32,90,87]]<br>input_list_id = id(input_list)<br><br>outputs = max_per_list(input_list)<br><br># memory corresponding to input_list is freed<br>del input_list<br><br>print(ctypes.c_long.from_address(input_list_id).value)<br>print(ctypes.cast(input_list_id, ctypes.py_object).value)<br><br>s = sum(outputs)</pre><p>Something similar happens with C++, but unlike Python, <strong>C++ does not have garbage collection</strong>. Thus heap space occupied by unwanted objects must be <strong>freed manually by the user</strong>.</p><pre>#include &lt;iostream&gt;<br><br>int main() {<br>    // pointer x is stored in stack but the actual array is stored in heap<br>    int* x = new int[10];<br>    // do some initialization<br>    for (int i = 0; i &lt; 10; i++) x[i] = 2*i;<br>    <br>    // pointer y is stored in stack<br>    // points to same memory location as x<br>    int* y = x;<br>    <br>    y[2] = 5;<br>    x[0] = 7;<br>    <br>    for (int i = 0; i &lt; 10; i++) std::cout &lt;&lt; x[i] &lt;&lt; &quot; &quot;;<br>    std::cout &lt;&lt; std::endl;<br>    <br>    for (int i = 0; i &lt; 10; i++) std::cout &lt;&lt; y[i] &lt;&lt; &quot; &quot;;<br>    std::cout &lt;&lt; std::endl;<br>    <br>    // deleting y will also delete the pointer to x and the heap space will<br>    // be freed.<br>    delete [] y;<br>    <br>    return 0;<br>}</pre><p>In the above C++ code, x and y are pointers in stack pointing to same object in the heap memory. When the pointer y is deleted, the memory is freed. This is unlike Python, where the reference counter is decremented by 1.</p><p>In C/C++ f<strong>or every malloc we need to call free()</strong> and <strong>for every new we need to call delete</strong> else we would be having memory leaks, since there is no garbage collector to take care of unwanted objects lying around in heap.</p><p>In Rust, we would write the sum of max per list program as follows:</p><pre>use std::isize::MIN;<br><br>fn sum_of_max_per_list(input_list:Vec&lt;Vec&lt;isize&gt;&gt;)-&gt;isize {<br>    let n:usize = input_list.len();<br>    let mut outputs:Vec&lt;isize&gt; = vec![MIN;n];<br>    <br>    for (i, _) in input_list.iter().enumerate() {<br>        for (_j, y) in input_list[i].iter().enumerate() {<br>            if *y &gt;= outputs[i] {<br>                outputs[i] = *y;<br>            }<br>        }<br>    }<br>    <br>    let mut s:isize = 0;<br>    for x in outputs.iter() {<br>        s += *x;<br>    }<br>    <br>    return s;<br>}<br>fn main() {<br>    let input_list:Vec&lt;Vec&lt;isize&gt;&gt; = vec![<br>        vec![10, 1, 12, 4],<br>        vec![5, 87, 13, 65],<br>        vec![10, 11, 12, 13, 14],<br>        vec![46, 1, 32, 90, 87],<br>    ];<br>    <br>    let s:isize = sum_of_max_per_list(input_list);<br>    println!(&quot;{:?}&quot;, s);<br>}</pre><p>There are few things to notice in the above code when we compare the same code in Python.</p><p>When we are passing the input_list as function argument, the <strong>ownership of the input_list changes</strong> and it is moved into the function. The implication of this is that if we wished to use input_list variable after calling the “sum_of_max_per_list” method, we would get a compilation error.</p><pre>fn main() {<br>    let input_list:Vec&lt;Vec&lt;isize&gt;&gt; = vec![<br>        vec![10, 1, 12, 4],<br>        vec![5, 87, 13, 65],<br>        vec![10, 11, 12, 13, 14],<br>        vec![46, 1, 32, 90, 87],<br>    ];<br>    <br>    let s:isize = sum_of_max_per_list(input_list);<br>    <br>    println!(&quot;{:?}&quot;, s);<br>    println!(&quot;{:?}&quot;, input_list);<br>}<br></pre><p>Compilation error:</p><pre>error[E0382]: borrow of moved value: `input_list`<br>  --&gt; src/main.rs:33:22<br>   |<br>23 |     let input_list:Vec&lt;Vec&lt;isize&gt;&gt; = vec![<br>   |         ---------- move occurs because `input_list` has type `Vec&lt;Vec&lt;isize&gt;&gt;`, which does not implement the `Copy` trait<br>...<br>30 |     let s:isize = sum_of_max_per_list(input_list);<br>   |                                       ---------- value moved here<br>...<br>33 |     println!(&quot;{:?}&quot;, input_list);<br>   |                      ^^^^^^^^^^ value borrowed here after move<br>   |<br>note: consider changing this parameter type in function `sum_of_max_per_list` to borrow instead if owning the value isn&#39;t necessary<br>  --&gt; src/main.rs:3:35<br>   |<br>3  | fn sum_of_max_per_list(input_list:Vec&lt;Vec&lt;isize&gt;&gt;)-&gt;isize {<br>   |    -------------------            ^^^^^^^^^^^^^^^ this parameter takes ownership of the value<br>   |    |<br>   |    in this function<br>   = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)<br>help: consider cloning the value if the performance cost is acceptable<br>   |<br>30 |     let s:isize = sum_of_max_per_list(input_list.clone());<br>   |                                                 ++++++++</pre><p>Thus, when we return from the sum_of_max_per_list method, the variable input_list goes out of scope and the heap memory is freed.</p><p>In Rust, there can be <strong>at-most one owner of any object</strong> in heap memory. If the owner goes out of scope, the object is deleted and memory is freed. Thus we <strong>do not need additional reference counting or garbage collection</strong>.</p><p>What if we needed to use the input_list after function call ?</p><p>In that case, the function can only borrow the input_list object but the owner is still the input_list variable.</p><p>The updated Rust code with borrowing:</p><pre>fn sum_of_max_per_list(input_list:&amp;Vec&lt;Vec&lt;isize&gt;&gt;)-&gt;isize {<br>    let n:usize = input_list.len();<br>    let mut outputs:Vec&lt;isize&gt; = vec![MIN;n];<br>    <br>    for (i, _) in input_list.iter().enumerate() {<br>        for (_j, y) in input_list[i].iter().enumerate() {<br>            if *y &gt;= outputs[i] {<br>                outputs[i] = *y;<br>            }<br>        }<br>    }<br>    <br>    let mut s:isize = 0;<br>    for x in outputs.iter() {<br>        s += *x;<br>    }<br>    <br>    return s;<br>}<br>fn main() {<br>    let input_list:Vec&lt;Vec&lt;isize&gt;&gt; = vec![<br>        vec![10, 1, 12, 4],<br>        vec![5, 87, 13, 65],<br>        vec![10, 11, 12, 13, 14],<br>        vec![46, 1, 32, 90, 87],<br>    ];<br>    <br>    let s:isize = sum_of_max_per_list(&amp;input_list);<br>    <br>    println!(&quot;{:?}&quot;, s);<br>    println!(&quot;{:?}&quot;, input_list);<br>}</pre><p>When the function is borrowing the object, by <strong>default it is an immutable borrow</strong> i.e. the input_list cannot be changed inside the function. If we needed to update the input_list, we would have to pass a mutable reference</p><pre>use std::isize::MIN;<br><br>fn sum_of_max_per_list(input_list:&amp;mut Vec&lt;Vec&lt;isize&gt;&gt;)-&gt;isize {<br>    let n:usize = input_list.len();<br>    let mut outputs:Vec&lt;isize&gt; = vec![MIN;n];<br>    <br>    for (i, x) in input_list.iter_mut().enumerate() {<br>        for (_j, y) in x.iter_mut().enumerate() {<br>            if *y &gt;= outputs[i] {<br>                outputs[i] = *y;<br>            }<br>            *y = -1;<br>        }<br>    }<br>    <br>    let mut s:isize = 0;<br>    for x in outputs.iter() {<br>        s += *x;<br>    }<br>    <br>    return s;<br>}<br>fn main() {<br>    let mut input_list:Vec&lt;Vec&lt;isize&gt;&gt; = vec![<br>        vec![10, 1, 12, 4],<br>        vec![5, 87, 13, 65],<br>        vec![10, 11, 12, 13, 14],<br>        vec![46, 1, 32, 90, 87],<br>    ];<br>    <br>    let s:isize = sum_of_max_per_list(&amp;mut input_list);<br>    <br>    println!(&quot;{:?}&quot;, s);<br>    println!(&quot;{:?}&quot;, input_list);<br>}</pre><p>But even if we are passing an object as a mutable reference to a function, we <strong>cannot move out i.e. change ownership of the object within the function scope</strong>. The below will throw compilation error.</p><pre>fn sum_of_max_per_list(input_list:&amp;mut Vec&lt;Vec&lt;isize&gt;&gt;)-&gt;isize {<br>    let n:usize = input_list.len();<br>    let mut outputs:Vec&lt;isize&gt; = vec![MIN;n];<br><br>    // will throw compilation error here<br>    let obj = *input_list;<br>    <br>    for (i, x) in input_list.iter_mut().enumerate() {<br>        for (_j, y) in x.iter_mut().enumerate() {<br>            if *y &gt;= outputs[i] {<br>                outputs[i] = *y;<br>            }<br>            *y = -1;<br>        }<br>    }<br>    <br>    let mut s:isize = 0;<br>    for x in outputs.iter() {<br>        s += *x;<br>    }<br>    <br>    return s;<br>}</pre><p>Compilation error:</p><pre>error[E0507]: cannot move out of `*input_list` which is behind a mutable reference<br> --&gt; src/main.rs:7:15<br>  |<br>7 |     let obj = *input_list;<br>  |               ^^^^^^^^^^^ move occurs because `*input_list` has type `Vec&lt;Vec&lt;isize&gt;&gt;`, which does not implement the `Copy` trait<br>  |<br>help: consider removing the dereference here<br>  |<br>7 -     let obj = *input_list;<br>7 +     let obj = input_list;<br>  |</pre><p>If we have to assign the value of the object to another variable in the above code, we need to clone or copy the original object (duplicating in the heap memory).</p><p>Here is how to do it:</p><pre>let obj = input_list.clone();</pre><p>Also be careful of the fact that <strong>for same object we cannot mix and match concurrent mutable and immutable references</strong>.</p><pre>fn sum_of_max_per_list(input_list:&amp;mut Vec&lt;Vec&lt;isize&gt;&gt;)-&gt;isize {<br>    let n:usize = input_list.len();<br>    let mut outputs:Vec&lt;isize&gt; = vec![MIN;n];<br>    <br>    // borrow immutably<br>    let first_element:&amp;isize = &amp;input_list[0][0];<br>    <br>    // borrow mutably<br>    for (i, x) in input_list.iter_mut().enumerate() {<br>        for (_j, y) in x.iter_mut().enumerate() {<br>            if *y &gt;= outputs[i] {<br>                outputs[i] = *y;<br>            }<br>            *y = -1;<br>        }<br>    }<br>    <br>    let mut s:isize = 0;<br>    for x in outputs.iter() {<br>        s += *x;<br>    }<br>    <br>    // immutable borrow used here<br>    println!(&quot;{:?}&quot;, *first_element);<br>    <br>    return s;<br>}</pre><p>So what we are doing above is, first we borrow input_list as immutable i.e. this value should not be changed to get the 1st element at index (0,0).</p><p>Then we borrow mutably, implying that the 1st element might be changed during this process but later on we are using the immutable borrow (to print the 1st element) hence compiler will throw an error.</p><pre>error[E0502]: cannot borrow `*input_list` as mutable because it is also borrowed as immutable<br>  --&gt; src/main.rs:9:19<br>   |<br>7  |     let first_element:&amp;isize = &amp;input_list[0][0];<br>   |                                 ---------- immutable borrow occurs here<br>8  |     <br>9  |     for (i, x) in input_list.iter_mut().enumerate() {<br>   |                   ^^^^^^^^^^ mutable borrow occurs here<br>...<br>23 |     println!(&quot;{:?}&quot;, *first_element);<br>   |                      -------------- immutable borrow later used here</pre><p>If we did not use the immutable borrow later on (line no. 23) we will not get the error.</p><p>These are some of the common things that a Rust developer must be aware of:</p><ol><li>Understanding <strong>when an object is moved</strong>. If an object is moved i.e. ownership changes, then any statement using the object later on (by non-owner) will cause compilation error.</li><li>In case an object is borrowed by a function, one <strong>cannot move out or change ownership within the function scope</strong>. Although can clone or copy the object.</li><li>In case an object is <strong>borrowed immutably</strong> by a function, one <strong>cannot change the value of the object</strong> within the function scope. Need to pass the reference as mutable.</li><li>If an object is borrowed mutably then we cannot simulataneously borrow the object as immutable or another mutably. This follows from the fact that if it is intended to update an object then until and unless the updates are completed, one cannot make another update simultaneously. Also if it is intended to read the object, then one cannot simulataneously update the object till the read is completed.</li></ol><p>If you think carefully all of the above makes sense if we are not using reference counting or garbage collection for heap memory cleanup.</p><p>Now let’s look at a complete <strong>Rust example to implement a simple Binary Search Tree.</strong></p><p>Here is the BST setup:</p><pre>use std::fmt::Debug;<br>pub trait MyTrait: PartialOrd + Debug + Copy {}<br><br>struct BST&lt;T&gt; where T: MyTrait {<br>    root_node: Option&lt;Box&lt;Node&lt;T&gt;&gt;&gt;,<br>}<br><br>impl&lt;T: MyTrait&gt; BST&lt;T&gt; {<br>    fn new() -&gt; Self {<br>        Self {<br>            root_node: None,<br>        }<br>    }<br>}<br><br>#[derive(Clone)]<br>struct Node&lt;T&gt; where T: MyTrait {<br>    val: T,<br>    lt_node: Option&lt;Box&lt;Node&lt;T&gt;&gt;&gt;,<br>    rt_node: Option&lt;Box&lt;Node&lt;T&gt;&gt;&gt;,<br>}<br><br>impl&lt;T: MyTrait&gt; Node&lt;T&gt; {<br>    fn new(&amp;val:&amp;T) -&gt; Self {<br>        Self {<br>            val,<br>            lt_node: None,<br>            rt_node: None,<br>        }<br>    }<br>}</pre><p>Since we are going to implement a BST for a generic type i.e. the values stored in BST nodes is “generic” i.e. could be integer, float, string, other complex types etc. But we will limit the types to integer, float and string.</p><p>To define a generic type and its properties, we create a new <strong>trait MyTrait</strong>.</p><p>The properties of MyTrait are <strong>PartialOrd </strong>i.e. we can compare two elements of type T using less than or greater than operators. This is required since during BST insertion, we will compare an incoming value with the value in current node in BST using &lt; or &gt; inequalities and depending on that we will go to either left or right subtree.</p><p>Note: In order to use ≤ and ≥ instead of &lt; or &gt;, we have to use the property <strong>Ord instead of PartialOrd</strong>.</p><p>Debug property allows us to print the BST node values and Copy property allows us to copy or clone the node values. For e.g. when we delete a node, we copy the value of its successor node into itself.</p><p>“struct” in Rust is similar to C/C++ struct. The method “new” is like the class constructor.</p><p>One interesting thing to note is that when we define the Node struct, in C/C++, we define the left and the right nodes as pointers to the same Node struct and similarly in Python:</p><pre>struct Node {<br>    int val;<br>    Node* lt_node;<br>    Node* rt_node;<br>};</pre><p>But in Rust, we have something like:</p><pre>struct Node {<br>    val: i32,<br>    lt_node: Option&lt;Box&lt;Node&gt;&gt;,<br>    rt_node: Option&lt;Box&lt;Node&gt;&gt;,<br>}</pre><p>When Rust creates the struct in the heap memory, it has to <strong>know how much memory it has to allocate in the heap</strong>. If we just we use Option&lt;Node&gt;, then it becomes recursive type and it becomes impossible to infer the size in heap memory to allocate for Node object.</p><p>If we use something similar to C/C++ i.e.</p><pre>struct Node {<br>    val: i32,<br>    lt_node: Option&lt;Node&gt;,<br>    rt_node: Option&lt;Node&gt;,<br>}</pre><p>Then we get compilation error:</p><pre>error[E0072]: recursive type `Node` has infinite size<br> --&gt; src/lib.rs:1:1<br>  |<br>1 | struct Node {<br>  | ^^^^^^^^^^^<br>2 |     val: i32,<br>3 |     lt_node: Option&lt;Node&gt;,<br>  |                     ---- recursive without indirection<br>  |<br>help: insert some indirection (e.g., a `Box`, `Rc`, or `&amp;`) to break the cycle<br>  |<br>3 |     lt_node: Option&lt;Box&lt;Node&gt;&gt;,<br>  |                     ++++    +</pre><p><strong>Box is a smart pointer in Rust</strong>. It can be used to break the infinite cycle.</p><p>The size of a Node object is the sum of the i32 value, and the sizes of the two Box pointers corresponding to lt_node and rt_node. I will not go into details of smart pointers in this post.</p><p>Next we define the insert method for BST struct as follows:</p><pre>impl&lt;T: MyTrait&gt; BST&lt;T&gt; {<br>    fn insert(&amp;mut self, root_node:&amp;mut Option&lt;Box&lt;Node&lt;T&gt;&gt;&gt;, &amp;val:&amp;T) {<br>        let new_node = Node::new(&amp;val);<br><br>        match root_node {<br>            Some(node) =&gt; {<br>                // root_node is not None<br>                if val &lt;= node.val {<br>                    if node.lt_node.is_none() {<br>                        node.lt_node = Some(Box::new(new_node));<br>                    }<br>                    else {<br>                        self.insert(&amp;mut node.lt_node, &amp;val);<br>                    }<br>                }<br>                else {<br>                    if node.rt_node.is_none() {<br>                        node.rt_node = Some(Box::new(new_node));<br>                    }<br>                    else {<br>                        self.insert(&amp;mut node.rt_node, &amp;val);<br>                    }<br>                }<br>            }<br>            None =&gt; {<br>                // root node is None<br>                *root_node = Some(Box::new(new_node));<br>            }<br>        }<br>    }<br>}</pre><p>Check if the root_node is not None, if root_node is None, create a new root node and assign to root node. If root_node is not None, we add the new node by calling the insert method recursively.</p><p>Note that we are passing the <strong>root_node as a mutable reference</strong>.</p><p>It needs to be mutable because the tree is updated by adding a new node at the leaf. <strong>The mutability is propagated upwards, towards the root node.</strong></p><p>Now let’s look at the delete method for BST:</p><pre>impl&lt;T: MyTrait&gt; BST&lt;T&gt; {<br>    // delete a node with value val<br>    fn delete(&amp;mut self, root_node:&amp;mut Option&lt;Box&lt;Node&lt;T&gt;&gt;&gt;, &amp;val:&amp;T) {  <br>        match root_node {<br>            Some(node) =&gt; {<br>                // if root_node is not None<br>                if val == node.val {<br>                    // found the node to delete<br>                    if (node.lt_node.is_none()) &amp;&amp; (node.rt_node.is_none()) {<br>                        *root_node = None;<br>                    }<br>                    else if node.lt_node.is_none() {<br>                        match &amp;node.rt_node {<br>                            Some(x) =&gt; {<br>                                node.val = x.val;<br>                                node.rt_node = None;<br>                            }<br>                            None =&gt; {}<br>                        }<br>                    }<br>                    else if node.rt_node.is_none() {<br>                        match &amp;node.lt_node {<br>                            Some(x) =&gt; {<br>                                node.val = x.val;<br>                                node.lt_node = None;<br>                            }<br>                            None =&gt; {}<br>                        }<br>                    }<br>                    else {<br>                        let mut new_node = &amp;node.rt_node;<br>                        let mut has_successor:bool = false;<br><br>                        // find successor node<br>                        while !new_node.is_none() {<br>                            match new_node {<br>                                Some(x) =&gt; {<br>                                    has_successor = true;<br>                                    node.val = x.val;<br>                                    new_node = &amp;x.lt_node;<br>                                }<br>                                None =&gt; {}<br>                            }<br>                        }<br><br>                        if has_successor {<br>                            self.delete(&amp;mut node.rt_node, &amp;node.val);<br>                        }<br>                    }<br>                }<br><br>                else if val &lt; node.val {<br>                    // go left to delete<br>                    self.delete(&amp;mut node.lt_node, &amp;val);<br>                }<br><br>                else {<br>                    // go right to delete<br>                    self.delete(&amp;mut node.rt_node, &amp;val);<br>                }<br>            }<br>            None =&gt; {}<br>        }<br>    }<br>}</pre><p>The deletion algorithm follows standard deletion in BST. If the node to be deleted is a leaf node, we simply delete it. Else if the node to be deleted has either a left or right child, we replace the node with its left or right child.</p><p>If node to be deleted has both children, then we <strong>find the successor node of this node</strong>. Copy the node value of successor to itself and delete the successor node. <strong>Note that the successor node is either a leaf node or has only right child.</strong></p><p>The final piece of the puzzle is defining the main function and how we create the BST and call the insert and delete methods on it.</p><pre>impl&lt;T: MyTrait&gt; BST&lt;T&gt; {<br>    // print tree as shown below<br>    //  10<br>    //  -- 5<br>    //  ---- 2<br>    //  ---- 9<br>    //  -- 20<br>    //  ---- 15<br>    //  ---- 30<br>    fn print_tree(&amp;self, root_node:&amp;Option&lt;Box&lt;Node&lt;T&gt;&gt;&gt;, level:usize) {  <br>        match root_node {<br>            Some(node) =&gt; {<br>                println!(&quot;{} {:?}&quot;, &quot;-&quot;.repeat(2*level), node.val);<br>                self.print_tree(&amp;node.lt_node, level+1);<br>                self.print_tree(&amp;node.rt_node, level+1);<br>            }<br>            None =&gt; {}<br>        }<br>    }<br>}<br><br>fn main() {<br>    // BST with values as unsigned integers<br>    let mut bst:BST&lt;usize&gt; = BST::new();<br>    let mut root:Option&lt;Box&lt;Node&lt;usize&gt;&gt;&gt; = None;<br><br>    bst.insert(&amp;mut root, &amp;10);<br>    bst.insert(&amp;mut root, &amp;20);<br>    bst.insert(&amp;mut root, &amp;5);<br>    bst.insert(&amp;mut root, &amp;9);<br>    bst.insert(&amp;mut root, &amp;2);<br>    bst.insert(&amp;mut root, &amp;15);<br>    bst.insert(&amp;mut root, &amp;30);<br><br>    bst.print_tree(&amp;root, 0);<br><br>    println!();<br>    bst.delete(&amp;mut root, &amp;10);<br>    bst.print_tree(&amp;root, 0);<br>}</pre><p>Although it looks like a clean implementation and it is infact true that <strong>if there are no compilation errors in Rust, the code is correct</strong>, but it takes a significant amount of practice to write compilation error free codes.</p><p>Most of the compiler errors stems from the earlier discussion points.</p><p>After implementing few algorithms and data structures in Rust, I believe that <strong>Rust codes are truly production grade</strong> and as a tech lead, I would want myself and my team members to use Rust extensively in all data intensive production workloads.</p><h4>References</h4><ol><li><a href="https://doc.rust-lang.org/book/title-page.html">The Rust Programming Language — The Rust Programming Language (rust-lang.org)</a></li><li><a href="https://doc.rust-lang.org/beta/rust-by-example/index.html">Introduction — Rust By Example (rust-lang.org)</a></li><li><a href="https://nnethercote.github.io/perf-book/title-page.html">Title Page — The Rust Performance Book (nnethercote.github.io)</a></li><li><a href="https://medium.com/geekculture/understanding-memory-management-in-rust-a341cfce9807">Understanding Memory Management in Rust | by Bijesh O S | Geek Culture | Medium</a></li><li><a href="https://dev.to/ender_minyard/rust-visualized-the-stack-the-heap-and-pointers-a5c">✨🥞 Rust Visualized: The Stack, the Heap, and Pointers — DEV Community</a></li><li><a href="https://os.phil-opp.com/heap-allocation/">Heap Allocation | Writing an OS in Rust (phil-opp.com)</a></li><li><a href="https://stackify.com/python-garbage-collection/">Python Garbage Collection: What It Is and How It Works (stackify.com)</a></li><li><a href="https://www.educative.io/blog/stack-vs-heap">Stack vs Heap: What’s the difference? (educative.io)</a></li><li><a href="https://instagram-engineering.com/dismissing-python-garbage-collection-at-instagram-4dca40b29172">Dismissing Python Garbage Collection at Instagram | by Instagram Engineering | Instagram Engineering (instagram-engineering.com)</a></li><li><a href="https://realpython.com/python-memory-management/">Memory Management in Python — Real Python</a></li></ol><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=ba0457ae9f31" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[HyperLogLog, Probabilities and Dynamic Programming …]]></title>
            <link>https://medium.com/gaussian-machine/hyperloglog-probabilities-and-dynamic-programming-7d93726c9e1e?source=rss-3d860626bbc7------2</link>
            <guid isPermaLink="false">https://medium.com/p/7d93726c9e1e</guid>
            <category><![CDATA[hash-function]]></category>
            <category><![CDATA[hyperloglog]]></category>
            <category><![CDATA[cardinality-estimation]]></category>
            <category><![CDATA[dynamic-programming]]></category>
            <category><![CDATA[probability]]></category>
            <dc:creator><![CDATA[Abhijit Mondal]]></dc:creator>
            <pubDate>Tue, 02 Jan 2024 13:07:46 GMT</pubDate>
            <atom:updated>2024-01-03T04:22:05.645Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*0kxX5u7cBkdh3O_3ppmj0g.jpeg" /><figcaption>source: quanta magazine</figcaption></figure><h3>HyperLogLog, Probabilities and Dynamic Programming …</h3><p>In this post we will be discussing the HyperLogLog algorithm for cardinality estimation.</p><p>Given a stream of input data points for e.g. the input could be IP addresses, user ids on a social networking site, geolocations such as latitude and longitude pairs for GPS enabled device and so on.</p><blockquote>In order to find the number of unique visitors to a website in the last 30 days assuming that there are <strong>1 million requests per second</strong>, there will be <strong>86.4 billion</strong> user ids (non-unique) logged into the system each day.</blockquote><blockquote>Multiply that by 30 days to get <strong>2.6 trillion user ids in a month</strong>.</blockquote><blockquote>Assuming that each user id is of size 32 bytes, we will need <strong>76 TB</strong> of data points to analyze and find the number of unique user ids from this data.</blockquote><p>Ofcourse, one can store all the 76TB on disk and scan the data to find the number of unique user ids but each such disk scan is an expensive operation. Based on the latency numbers chart, it takes somewhere around 10-20ms to read 1 MB of data from disk, thus reading <strong>76 TB it will take somewhere around 15-20 hours</strong> to read all of that data.</p><p><a href="https://gist.github.com/jboner/2841832">Latency Numbers Every Programmer Should Know (github.com)</a></p><p>If we store all that data in memory, we need to have at-least <strong>4864 RAMs each of 16GB</strong> and since memory is expensive than storing on disk, this option seems infeasible at the moment.</p><blockquote>Alternatively, we can use a ‘set’ data structure to store the stream of user ids. The set could be implemented using either a <strong>hash table or a balanced binary search tree such as AVL Tree or Skip List</strong>.</blockquote><p>The space complexity is O(n) where n is the number of unique user ids. If n ~ O(10⁹), we would still requires somewhere around <strong>30GB of RAM</strong> for storing the set.</p><blockquote>Can we live with some error in the counts of the number of unique visitors but use somewhere around <strong>1 KB memory</strong> only?</blockquote><blockquote>This can be a desired use-case when the exact count do not matter but approximate is enough such as <strong>counting the number of unique visitors</strong> to a website or the <strong>number of virtual machines created</strong> on a cloud platform etc.</blockquote><p>The idea behind HyperLogLog is pretty intuitive.</p><p>Assuming we have a ‘good’ hash function that generates hashes with an uniform distribution in the range [0, 2³² -1].</p><blockquote>If the maximum number of trailing zeros in the binary representation of the hash is K, then the approximate cardinality is 2^K.</blockquote><p>For e.g. if we use the Murmur3 family of hash functions, then the 32 bit hash of the string “Hello world” is:</p><pre>hsh = mmh3.hash(&quot;Hello world&quot;, 42, signed=False) &amp; ((1 &lt;&lt; 32)-1)<br>print(bin(hsh))<br><br>0b10010000010010111101110011111100</pre><p>We can see that the number of trailing zeros is 2.</p><p>Let X be the event that we saw K trailing zeros in the binary representation of the hash.</p><p>The number of possible hashes with trailing K bits as 0 is 2^(N-K) because we can place 0 or 1 in each of the remaining N-K places.</p><p><strong>Thus the probability that a hash ends with K zeros is :</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/256/1*9oP7P-7198K2zOqVHwMK6w.png" /></figure><p>To calculate the expected number of hashes before we see K trailing zeros, let the expected number of hashes be E. Then</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/552/1*EdIuziJGGWj9HKdAs9XoEQ.png" /></figure><p>If we see an event with K trailing zeros, then we add the term 1*p, else if we do not see this event, then we repeat again, thus if we do not see K trailing zeros, then the number of trials is 1+E.</p><p>Solving for E, we get:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/171/1*y97KwA9dM8MSLetTGndvkw.png" /></figure><p><strong>Thus the expected number of hashes after which we see K trailing zeros is 2^K.</strong></p><p>The standard deviation is given by:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/373/1*qbevr5us7ApIQmlWHKrRYg.png" /></figure><blockquote>Note that the above analysis do not assume that the maximum number of trailing zeros seen is K, it just assumes that we see an event with K trailing zeros.</blockquote><blockquote>In HLL, instead of considering the event that we see a hash with K trailing zeros we consider the event that the maximum number of trailing zeros seen is K.</blockquote><p>The benefit of the later strategy in streaming data is that the <strong>maximum number of trailing zeros is a non-decreasing function</strong> which correlates with the fact that <strong>cardinality is always non-decreasing</strong>.</p><p>The former strategy is not useful because if at time T we see a hash with 10 trailing zeros and at time T+1, a hash with 5 trailing zeros then at time T+1, the expected cardinality is lower than at time T which contradicts with our intuition.</p><p>Let’s calculate the probability that the maximum number of trailing zeros seen so far is K. Let the number of inputs seen so far be n.</p><p>Let r = (2^N), N is the total number of bits.</p><p>Let P be the number of ways by which we can have n N-bit numbers such that the number of trailing zeros ranges from 0 to K for all n numbers.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/703/1*kLzOQbIpOdAQTjeGOnc9hg.png" /></figure><p>Similarly, let Q be the number of ways by which we can have n N-bit numbers such that the number of trailing zeros ranges from 0 to K-1 for all n numbers.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/683/1*kVGInd9TOT9t8j85acZH3w.png" /></figure><p>Then the number of ways by which we can have n N-bit numbers such that at-least one of n has K trailing zeros = P-Q</p><p>Probability that the maximum number of trailing zeros seen is K:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/955/1*UJciUTc65gYF8P7TiTWipQ.png" /></figure><p>The expected cardinality in HLL with a single hash function is given to be :</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/788/1*Wv7MhPVPN7iSPi73eFbF3w.png" /></figure><p>The variance of the cardinality is given as:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/943/1*yFsNxYOLeywTobsVpk-3jg.png" /></figure><p>The standard deviation and standard error is given as:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/431/1*thhL4-lxb9ka6i5jqmOJVw.png" /></figure><p><strong>For N=32 and n = 2³², the standard error is high as 51610.</strong></p><p>One can experimentally verify this is true by running the following Python simulation:</p><pre>import random, mmh3, math, numpy as np, string<br><br>def get_trailing_zeros(n, N):<br>    if n == 0:<br>        j = N<br>    else:<br>        j = 0<br>        while n &gt; 0:<br>            if (n &amp; 1) == 1:<br>                break<br><br>            n = n &gt;&gt; 1<br>            j += 1<br><br>    return j<br><br><br>N = 32<br>n = 1 &lt;&lt; N<br>a = []<br><br>for j in range(1000):<br>    g = -1<br>    for i in range(n):<br>        data = &#39;&#39;.join(\<br>                random.choices(string.ascii_lowercase + string.digits, <br>                                   k=random.randint(1, 20)))<br>        <br>        # generate hash between 0 and 2^N-1<br>        hsh = mmh3.hash(data, 42, signed=False) &amp; ((1&lt;&lt;N)-1)<br><br>        # number of trailing zeros in binary of hsh<br>        b = get_trailing_zeros(hsh, N)<br>        g = max(g, b)<br><br>    # calculate cardinality<br>    a += [1&lt;&lt;g]<br><br># standard deviation<br>print(np.std(a))</pre><p>Assuming that we have seen n inputs so far in the data stream and the leftmost position of the first one in the binary representations of the hashes is K i.e. the maximum number of trailing zeros is K-1.</p><pre>......100<br>.......10<br>....10000<br>........1<br><strong>..1000000</strong></pre><p>Given this data, can we estimate the expected cardinality of the stream ?</p><p>Let X be the total number of ways of obtaining a maximum of K-1 trailing zeros with n input hashes. Then X is calculated as:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/761/1*dW5r6mfLZFaR_1dcFFfk5Q.png" /></figure><p>Using the same reasoning as seen earlier.</p><p><strong>Let a be the number of ways of obtaining K-1 trailing zeros and 1 at the K-th place in a N-bit hash .</strong></p><p>Then:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/219/1*2ylSQnkZ6_TRZn1Rxr43kg.png" /></figure><p><strong>Let b be the number of ways of obtaining less than K-1 trailing zeros in an N-bit hash.</strong></p><p>Then:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/440/1*T--vuhY9FLQMi3PZAOTZ5A.png" /></figure><blockquote>To generate a sequence of n input hashes such that <strong>at-least one of the input hashes</strong> have K-1 trailing zeros and the remaining have less than K-1 trailing zeros.</blockquote><blockquote>Let the <strong>cardinality with n hashes be h</strong> i.e. out of n hashes seen, h are unique hashes.</blockquote><p>I can choose j hashes from ‘a’ hashes (‘a’ is defined above) and h-j hashes from ‘b’ hashes varying j from 1 to h. After filling the h hashes out of n hashes, I can fill the remaining n-h hashes using repetition from the h unique hash values.</p><blockquote>Let <strong>H(n, j, h-j)</strong> be the number of ways of arranging n input hashes with h unique hashes and j hashes from set ‘a’ i.e. equal to K-1 leading zeros and h-j hashes from set ‘b’ i.e. less than K-1 leading zeros.</blockquote><p>Then we can use the following recursion relation to solve for H:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1019/1*ZkGUdtmMjdZz6FLnwaS8eg.png" /></figure><p>The 1st component refers to the case where we have formed n-1 hashes with j hashes from set ‘a’ and h-j from set ‘b’. For the remaining n-th hash, I can pick any one of the h hashes selected so far.</p><p>The 2nd component refers to the case where we have formed n-1 hashes with j-1 hashes from set ‘a’ and h-j from set ‘b’. For the remaining n-th hash, I have to pick one of the a-(j-1) hashes from set ‘a’ because I need to have j hashes from set ‘a’.</p><p>The 3rd component refers to the case where we have formed n-1 hashes with j hashes from set ‘a’ and h-j-1 from set ‘b’. For the remaining n-th hash, I have to pick one of the b-(h-j-1) hashes from set ‘b’ because I need to have h-j hashes from set ‘b’.</p><p>Here is the <strong>Python code to find H using memoization technique</strong>:</p><pre>def H(n, x, y, dp):<br>    if x &lt; 0 or y &lt; 0:<br>        dp[(n, x, y)] = 0<br>        return dp[(n, x, y)]<br>        <br>    if n &lt; x+y:<br>        dp[(n, x, y)] = 0<br>        return dp[(n, x, y)]<br><br>    if n == 1:<br>        if x+y == 1:<br>            if x == 1:<br>                dp[(n, x, y)] = a<br>            else:<br>                dp[(n, x, y)] = b<br>        else:<br>            dp[(n, x, y)] = 0<br>            <br>        return dp[(n, x, y)]<br><br>    u = H(n-1, x, y, dp) if (n-1, x, y) not in dp \<br>                else dp[(n-1, x, y)]<br><br>    v = H(n-1, x-1, y, dp) if (n-1, x-1, y) not in dp \<br>                else dp[(n-1, x-1, y)]<br><br>    w = H(n-1, x, y-1, dp) if (n-1, x, y-1) not in dp \<br>                else dp[(n-1, x, y-1)]<br><br>    dp[(n, x, y)] = u*(x+y) + (a-x+1)*v + (b-y+1)*w<br>        <br>    return dp[(n, x, y)]</pre><p><strong>Compute H for all different cardinalities h ranging from 1 to n</strong>.</p><blockquote>To find the <strong>expected cardinality</strong>, multiply each cardinality h with H and divide by X (calculated above) and take their sum.</blockquote><pre>n = 300<br>N = 10<br>k = 7<br><br>q = (1 &lt;&lt; N)<br>x = int((q*(1-(1/2)**k))**n - (q*(1-(1/2)**(k-1)))**n)<br><br>a = (1 &lt;&lt; (N-k))<br>b = int(q*(1-(1/2)**(k-1)))<br><br>dp = {}<br>            <br>e = 0<br>for h in range(1, n+1):<br>    y = 0<br>    for j in range(1, h+1):<br>        y += H(n, j, h-j, dp) if (n, j, h-j) not in dp else dp[(n, j, h-j)]<br><br>    e += h*(y/x)<br><br>print(e)</pre><p>The above code is for finding the expected cardinality when we have seen 300 input data points each of 10 bits and the maximum number of trailing zeros seen is 7.</p><p><strong>The expected cardinality is somehwere around 260.</strong></p><p>We can verify this is true by running a simulation.</p><pre>import random, mmh3, math, string<br><br>v, c = 0, 0<br>n = 300<br>N = 10<br>k = 7<br><br>for j in range(10000):<br>    nums = random.choices(range(10**9), k=n)<br>    g = -1<br>    d = set()<br>    for i in range(len(nums)):<br>        data = &#39;&#39;.join(\<br>                random.choices(string.ascii_lowercase + string.digits, <br>                                   k=random.randint(1, 20)))<br><br>        hsh = mmh3.hash(data, 42, signed=False) &amp; ((1&lt;&lt;N)-1)<br>        b = get_trailing_zeros(hsh, N)<br>        g = max(g, b)<br>        d.add(hsh)<br><br>    if g == k:<br>        v += len(d)<br>        c += 1<br><br>print(v/c)</pre><p><strong>We will see that the expected cardinality is also somewhere around 260.</strong></p><p>This is one way to find the cardinality without having to store all the input data but the issue is that :</p><ol><li>We are using <strong>O(n³) memory</strong> to store the cached values dp.</li><li><strong>Running time complexity is O(n³)</strong>.</li></ol><p>Both these drawbacks suggest that we cannot use this analysis to calculate the expected cardinality.</p><blockquote>Coming back to our HLL algorithm, instead of using a single hash function, what if we use multiple hash functions. Will that bring the standard error down?</blockquote><p>Using m hash functions, let <strong>K1, K2, … Km be the maximum number of trailing zeros corresponding to each hash function</strong>.</p><p>Then the estimated cardinalities from each hash are respectively 2^K1, 2^K2 … and so on. To get the final cardinality we can take the geometric mean of the individual cardinalities i.e.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/483/1*D1Wka8byW_CZhGojlmNJZw.png" /></figure><p>The expected cardinality is the sum over all possible combinations of K1, K2 … upto Km given to be:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*jfeIagDiaTA82yIfFGfazw.png" /></figure><p>Number of possible combinations of K1, K2, … Km is:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/200/1*QK9w4nWSz1jDCpMbRObHWw.png" /></figure><p>If we run the below Python code, we can see that the standard deviation decreases on increasing m i.e. the number of hash functions.</p><pre>import math<br><br># Function to generate all possible combinations of k1, k2, ..., km<br>def hn(N, m, curr, out):<br>    if m == 0:<br>        out += [curr]<br><br>    else:<br>        for k in range(N+1):<br>            hn(N, m-1, curr + [k], out)<br><br>N = 10<br>n = 1 &lt;&lt; N<br><br>for m in range(1, 10):<br>    s1, s2 = 0, 0<br>    <br>    out = []<br>    hn(N, m, [], out)<br>    <br>    # For each combination of k1, k2, ..., km<br>    for x in out:<br>        p = 1<br><br>        # Mean of the k1, k2, ..., km<br>        q = 2**(1.0 + sum(x)/m)<br>        <br>        for y in x:<br>            p *= ((1-(1/2)**(y+1))**n - (1-(1/2)**y)**n)<br>    <br>        s1 += p*q**2<br>        s2 += p*q<br><br>    print(m)<br><br>    # print standard error<br>    print(math.sqrt(s1-s2**2)/math.sqrt(n))</pre><p>The plot of the standard error vs. number of hash functions m, looks like one below:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*PXF-LhCzqlLCx-OeQH_cAg.png" /><figcaption>standard error vs. number of hash functions (m)</figcaption></figure><p>Due to the combinatorial nature of the number of hash functions on the different combinations Ki, calculating for higher values of m becomes exponentially difficult, hence we have shown till m=6.</p><p>Here is a python code to demonstrate an estimation of cardinality using 1000 independent hash functions:</p><pre>import random, mmh3, math, numpy as np, string<br><br>N = 32<br>m = 1000<br>k = 5123<br><br># Generate m seeds for murmur3 hash corresponds to m hash functions<br>seeds = [random.randint(0, 10**5) for _ in range(m)]<br><br># Generate k random strings<br>data = list(set([&#39;&#39;.join(\<br>            random.choices(string.ascii_lowercase + string.digits, <br>              k=random.randint(1, 20))) for _ in range(k)]))<br><br># True cardinality<br>print(len(data))<br><br>g = [-1]*m<br><br>for i in range(10*k):<br>    h = random.sample(data, 1)[0]<br>    <br>    for j in range(m):<br>        # generate hash between 0 and 2^N-1<br>        hsh = mmh3.hash(h, seeds[j], signed=False) &amp; ((1&lt;&lt;N)-1)<br><br>        # number of leading zeros in binary of hsh<br>        b = get_trailing_zeros(hsh, N)<br>        g[j] = max(g[j], b)<br><br># Estimated cardinality<br>w = sum(g)/m<br>a = 0.79402*2**w<br><br>print(a)</pre><p>In the above code, we are multiplying estimated cardinality by <strong>0.79402</strong>. This constant is found out in the paper by the original authors.</p><blockquote>One of the drawbacks with the above approach is that we have to use m different hash functions and if m is large i.e. m &gt; 1000, the time complexity is quite high i.e. <strong>O(m*w) for each input string of length w present in the stream.</strong></blockquote><blockquote>Another approach to simulate m different hash functions, is that we take the first p bits from the hash as a bucket. Thus with <strong>p bits, we will have 2^p number of buckets</strong>. Estimate the cardinality using the <strong>remaining N-p bits</strong>.</blockquote><p>Similar to m hash functions, we take the geometric mean of the cardinality in each bucket <strong>but the final cardinality has to be multiplied by m.</strong></p><p>Because the input stream is divide into <strong>m sub-streams</strong> unlike m hash functions which are applied to all inputs, here each bucket corresponds to approximately n/m inputs.</p><p>The updated code for using 1st p bits as a bucket identifier:</p><pre>import random, mmh3, math, numpy as np, string<br><br>N = 32<br>p = 10<br>m = 1 &lt;&lt; p<br>k = 51236<br><br># Generate k random strings<br>data = list(set([&#39;&#39;.join(\<br>            random.choices(string.ascii_lowercase + string.digits, <br>              k=random.randint(1, 20))) for _ in range(k)]))<br><br># True cardinality<br>print(len(data))<br><br>g = [-1]*m<br><br>for i in range(10*k):<br>    h = random.sample(data, 1)[0]<br>    hsh = mmh3.hash(h, 42, signed=False) &amp; ((1&lt;&lt;N)-1)<br><br>    # First p bits is for bucket index r<br>    # Remaining N-p bits are for finding the cardinality<br>    u = N-p<br><br>    r = hsh &gt;&gt; u<br>    q = hsh &amp; ((1&lt;&lt;u)-1)<br><br>    # number of leading zeros in binary of quotient q above<br>    b = get_trailing_zeros(q, u)<br>    g[r] = max(g[r], b)<br><br># Estimated cardinality<br>w = sum(g)/m<br>a = 0.79402*m*2**w<br><br>print(a)</pre><p><strong>For each input in the stream, the time complexity is now reduced to O(w) from O(m*w).</strong></p><p>The updated expected cardinality when we are using m buckets instead of m hashes can be written as follows:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6gcXz04ar0InNHatY8I7zw.png" /></figure><p>Note that now we are using the additional terms because out of n inputs, n1 can go into 1st bucket, n2 into 2nd bucket and so on. The maximum number of trailing zeros in bucket j is Kj (same as before).</p><p>The code for computing standard errors is updated as follows:</p><pre># Compute nCr values for r from 0 to n using dynamic programming<br>def ncr(n):<br>    cache = [[0]*(n+1) for i in range(n+1)]<br>    <br>    for i in range(n+1):<br>        for j in range(i+1):<br>            if j == 0 or i == j:<br>                cache[i][j] = 1<br>            else:<br>                cache[i][j] = cache[i-1][j] + cache[i-1][j-1]<br>    <br>    return cache<br><br># Compute the term inside the 2nd summation in the expectation formula<br># dp = memoization variable<br>def gn(arr, i, n, m, u, dp):<br>    if m == 0:<br>        return 0 if n &gt; 0 else 1<br>        <br>    s = 0<br>    for j in range(n+1):<br>        p = (1-(1/2)**(arr[i]+1))**j - (1-(1/2)**arr[i])**j<br><br>        a = gn(arr, i+1, n-j, m-1, u, dp) if (n-j, m-1) not in dp \<br>            else dp[(n-j, m-1)]<br><br>        s += p*u[n][j]*a<br><br>    dp[(n, m)] = s<br>    return s<br><br><br>N = 5<br>n = 1 &lt;&lt; N<br>u = ncr(n)<br><br>for m in range(1, 10):<br>    # Get all combinations of k1, k2, ... km similar to previous<br>    out = []<br>    hn(N, m, [], out)<br>    <br>    s1, s2 = 0, 0<br>    <br>    # Compute the 1st summation in the above expectation formula<br>    for x in out:<br>        p = gn(x, 0, n, m, u, {})/m**n<br>        q = 2**(1.0 + sum(x)/m)<br>        <br>        s1 += p*q**2<br>        s2 += p*q<br>    <br>    print(m)<br>    print(math.sqrt(s1-s2**2)/math.sqrt(n))</pre><p>Due to the combinatorial nature of the expectation, it is computationally expensive to compute the standard errors for values of N beyond 6 and m beyond 5.</p><p>The final version of HLL has few other modifications such as :</p><ol><li>Using <strong>harmonic mean</strong> instead of geometric mean</li><li>Handling cases for <strong>small and large cardinalities</strong> separately.</li></ol><p>The final code looks like as follows:</p><pre>import mmh3, random, math<br>from scipy.stats import hmean<br>import numpy as np<br><br>class HyperLogLog:<br>    def __init__(self, p=10):<br>        self.N = 32<br>        self.p = p<br>        self.m = 1&lt;&lt;p<br>        self.buckets = [-1]*self.m<br>        <br>        self.alpha = 0.7213/(1+1.079/self.m)<br><br>        if self.m == 16:<br>            self.alpha = 0.673<br>        elif self.m == 32:<br>            self.alpha = 0.697<br>        elif self.m == 64:<br>            self.alpha = 0.709<br>    <br>    def add(self, data):<br>        h = mmh3.hash(data, 42, signed=False) &amp; ((1&lt;&lt;self.N)-1)<br><br>        # Exclude the 1st p bits in h<br>        v = self.N-self.p<br>        <br>        # Quotient using remaining 22 bits<br>        q = h &amp; ((1&lt;&lt;v)-1) <br><br>        # Remainder is first p=10 bits<br>        r = h &gt;&gt; v<br><br>        # Number of trailing zeros of quotient q<br>        j = get_trailing_zeros(q, v)<br><br>        self.buckets[r] = max(self.buckets[r], j+1)<br>    <br>    def get_size(self):<br>        u = 1 &lt;&lt; self.N<br>        v = [2**x for x in self.buckets]<br>        n = self.alpha*self.m*hmean(v)<br>        <br>        # Case for small cardinalities<br>        if n &lt;= 2.5*self.m:<br>            z = sum([self.buckets[i] == 0 for i in range(self.m)])<br>            if z != 0:<br>                return self.m*math.log(self.m/z)<br>        <br>        # Case for very large cardinalities<br>        elif n &gt; (1/30)*u:<br>            return -u*math.log(1-(n/u))<br>        <br>        return n</pre><blockquote>There are 1024 buckets and in each bucket we are storing the maximum number of trailing zeros K which ranges from 0 to 22 (10 bits out of 32 bits for bucket). To store K for range of values from 0 to 22, we need 5 bits.</blockquote><blockquote>Thus each of the 1024 buckets is storing 5 bits, thus total = <strong>1024*5 bits = 0.625 KB</strong>. The error rate from the above code varies around 1–3%.</blockquote><blockquote>Compare this to using 30GB of memory for accurate cardinality estimation.</blockquote><p>Using harmonic mean, the standard error values are lower as compared to using geometric mean.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*2zP2vhLGUig1bynxLTVDeQ.png" /><figcaption>standard error with harmonic mean</figcaption></figure><p>Read more about the theory behind the hyperloglog and error bounds in the original paper <a href="https://arxiv.org/pdf/2205.11327v1.pdf">here</a>.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=7d93726c9e1e" width="1" height="1" alt=""><hr><p><a href="https://medium.com/gaussian-machine/hyperloglog-probabilities-and-dynamic-programming-7d93726c9e1e">HyperLogLog, Probabilities and Dynamic Programming …</a> was originally published in <a href="https://medium.com/gaussian-machine">Gaussian Machine</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Demystifying CPU Caches with Examples]]></title>
            <link>https://mecha-mind.medium.com/demystifying-cpu-caches-with-examples-810534628d71?source=rss-3d860626bbc7------2</link>
            <guid isPermaLink="false">https://medium.com/p/810534628d71</guid>
            <category><![CDATA[low-latency]]></category>
            <category><![CDATA[cpp]]></category>
            <category><![CDATA[cpu]]></category>
            <category><![CDATA[cache]]></category>
            <category><![CDATA[programming]]></category>
            <dc:creator><![CDATA[Abhijit Mondal]]></dc:creator>
            <pubDate>Wed, 22 Nov 2023 08:44:01 GMT</pubDate>
            <atom:updated>2023-11-22T13:51:45.018Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*frPEV98-1cMx3NQPADLQfQ.jpeg" /><figcaption>source: quanta magazine</figcaption></figure><p>This post takes inspiration from one of the famous blogs on the different CPU cache effects:</p><p><a href="http://igoro.com/archive/gallery-of-processor-cache-effects/">Gallery of Processor Cache Effects (igoro.com)</a></p><p>Some notable factors that can affect the performance of a computer program utilizing the CPU cache are as follows:</p><ol><li>Impact of Cache Lines</li><li>Cache Associativity</li><li>False Cache Line Sharing</li></ol><p>Before getting into each of the above cache performance parameters, let us go over some important pointers regarding CPU caches.</p><ul><li>Reading from and writing to main memory (RAM) is expensive as compared to CPU caches.</li><li>According to the <a href="https://cs61.seas.harvard.edu/site/2022/Storage">CS61 course website</a>, the access times for RAM (main memory) is <strong>62.9 ns</strong> whereas it is <strong>12.8 ns for L3 cache, 3.3 ns for L2 cache and 1.1 ns for L1 cache</strong>.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/596/0*YcgYk__yJxbKTb0p.jpeg" /></figure><ul><li>During each read query, it is first checked in CPU’s L1 cache, if it is found then returned, else check L2 cache and so on till L3 cache or L4 cache (if present).</li><li>If not found in any CPU caches, fetch from main memory and add to L1, L2 and L3 cache.</li><li>When a byte is read from main memory, not only the byte is returned but a block of bytes is returned known as the cache line.</li><li>Generally the cache line is 64 bytes. Any read for a byte in the same cache line is read from either L1, L2 or L3 cache because the cache line of 64 bytes is already cached in the CPU.</li><li>For e.g. if the cache lines are aligned s.t. 0–63 bytes is one cache line, 64–127 is next one and so on. Then if byte 10 is read, all bytes from 0 to 63 is transferred from RAM to CPU and cached. Next if request for byte 57 is made, it is already present in CPU cache since 0 &lt;= 57 &lt;= 63, hence read from cache.</li><li>A cache line read from main memory can occupy one of N cache slots i.e. if an existing cache line already occupies one of the N slots, then the incoming cache line can only occupy one of remaining N-1 slots.</li><li>If all the N slots are occupied then we have to evict one of the N slots to make room for the new cache line. LRU policy is used to evict cache slots.</li></ul><p><a href="https://en.wikipedia.org/wiki/Cache_replacement_policies">Cache replacement policies — wikipedia</a></p><h3>Finding L1, L2 and L3 cache sizes</h3><p>In Linux, we can issue the following commands to find the L1, L2 and L3 cache sizes:</p><pre>getconf -a | grep CACHE</pre><p>The output:</p><pre>LEVEL1_ICACHE_SIZE                 32768<br>LEVEL1_ICACHE_ASSOC<br>LEVEL1_ICACHE_LINESIZE             64<br>LEVEL1_DCACHE_SIZE                 49152<br>LEVEL1_DCACHE_ASSOC                12<br>LEVEL1_DCACHE_LINESIZE             64<br>LEVEL2_CACHE_SIZE                  1310720<br>LEVEL2_CACHE_ASSOC                 20<br>LEVEL2_CACHE_LINESIZE              64<br>LEVEL3_CACHE_SIZE                  12582912<br>LEVEL3_CACHE_ASSOC                 12<br>LEVEL3_CACHE_LINESIZE              64<br>LEVEL4_CACHE_SIZE                  0<br>LEVEL4_CACHE_ASSOC<br>LEVEL4_CACHE_LINESIZE</pre><p>Size of L1 instruction cache (cache for storing the program) = 32768 bytes or 32 KB.</p><p>Size of L1 data cache (cache for storing the data) = 49152 bytes or 48 KB.</p><p>Note that the L1 cache exists in each core of the CPU i.e. if a CPU is 4-core, then each core has 32 KB of L1 instruction cache and 48KB of L1 data cache. Total size of L1 data cache is 48*4 KB = 192 KB.</p><p>Also L2 cache is present in each core of the CPU. Each core of CPU has 1.25 MB of cache, thus total size of L2 cache for a 4-core CPU is 1.25*4 = 5 MB.</p><blockquote>But L3 cache is shared across all cores of the CPU. Total size of L3 cache is 12 MB.</blockquote><p><strong>Each byte present in L3 cache is also present in L2 and L1 cache.</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/651/0*eIfGhRLXPPZeukX4.png" /></figure><p>Cache line size i.e. number of bytes prefetched for each byte read from main memory is 64 bytes.</p><blockquote>L1 data cache associativity is 12 i.e. each cache line can occupy one of 12 slots in the L1 cache. Similarly for L2 cache, each cache line can occupy one of 20 slots and for L3 cache can occupy one of 12 cache slots.</blockquote><h3>Impact of Cache Lines</h3><p>Assuming you have an integer array of size N, in the first case read M integers contiguously starting at index 0 to index M-1.</p><p>Since the cache line size is 64 bytes or 16 integers (each integer is 4 bytes), reading M integers contiguously requires accessing approximately M/16 cache lines.</p><p>More importantly each cache line is accessed from main memory at-most once.</p><pre>int main(int argc, char* argv[]) {<br><br>    const int length = 512 * 1024 * 1024;<br>    const int cache_line_size = 16;<br>    const int m = length/cache_line_size;<br><br>    int *arr = new int[length];<br><br>    // contiguous access<br>    auto start = std::chrono::high_resolution_clock::now();<br>    for (int i = 0; i &lt; m; i++) <br>        arr[i]++;<br>    auto stop = std::chrono::high_resolution_clock::now();<br><br>    auto duration = <br>    std::chrono::duration_cast&lt;std::chrono::milliseconds&gt;(stop - start);<br>    <br>    std::cout &lt;&lt; (float) duration.count() &lt;&lt; std::endl;<br><br>    free(arr);<br>    return 0;<br>}</pre><p>Compiling the above program with the default g++ compiler (v11.3.0) options:</p><pre>g++ cache_line.cpp -o cache_line<br>./cache_line</pre><p>Time taken in this case is 296 ms.</p><p>But if M integers are read with a jump of step=16 i.e. 0, 16, 32, … 16*(M-1), then although the number of elements scanned is still M but the number of cache lines accessed is 16*M/16 = M.</p><p>Thus in this case we are accessing <strong>16 times more cache lines.</strong></p><pre>int *arr = new int[length];<br>    <br>auto start = std::chrono::high_resolution_clock::now();<br>for (int i = 0; i &lt; m*cache_line_size; i+=cache_line_size) <br>    arr[i]++;<br>auto stop = std::chrono::high_resolution_clock::now();<br><br>auto duration = <br>std::chrono::duration_cast&lt;std::chrono::milliseconds&gt;(stop - start);<br><br>std::cout &lt;&lt; (float) duration.count() &lt;&lt; std::endl;</pre><p>Time taken in this stepped access case is 1871 ms.</p><blockquote>This clearly shows that although we are iterating over same number of indices M, but the amount of work done in fetching the <strong>additional cache lines in the 2nd case is significantly high as compared to the 1st case</strong>.</blockquote><p>In the 1st case when we fetch index 0, all indices from 0 to 15 are also fetched and cached in L1, L2 and L3 cache, hence updating the indices from 1 to 15 is done within the CPU cache which is not the case when we are doing stepped access.</p><p>In a 3rd scenario, we are iterating with a step size of 32, but we are updating integers at index i and i+16 i.e. we are still accessing same number of cache lines.</p><pre>int *arr = new int[length];<br>    <br>auto start = std::chrono::high_resolution_clock::now();<br>for (int i = 0; i &lt; m*cache_line_size; i+=2*cache_line_size) {<br>    arr[i]++;<br>    arr[i+cache_line_size]++;<br>}<br>auto stop = std::chrono::high_resolution_clock::now();<br><br>auto duration = <br>std::chrono::duration_cast&lt;std::chrono::milliseconds&gt;(stop - start);<br><br>std::cout &lt;&lt; (float) duration.count() &lt;&lt; std::endl;</pre><p>Time taken in this case is around 1500 ms.</p><blockquote>The purpose of the 3rd scenario is to show that although we are iterating over half the number of indexes in the array, but the running time should remain almost same since we are <strong>accessing the same number of cache lines</strong>.</blockquote><p><strong>Key Takeaway</strong> : Performance is dependent not only on how many elements we are looping over but also <strong>how many cache lines we are accessing from main memory</strong>.</p><h3>False Cache Line Sharing</h3><p>Let’s say there is an array of size N and using 4 threads A, B, C and D we repeatedly update the integers at indices i, j, k and l.</p><p>Now if i, j, k and l belong to the same cache line for e.g. i=0, j=1, k=2 and l=3, then whenever a thread updates an integer, the entire cache line is invalidated and the entire cache line is again fetched from main memory.</p><pre>void update(int *inp, int j) {<br>    for (int i = 0; i &lt; 1000000; i++) inp[j]++;<br>}<br><br>int main(int argc, char* argv[]) {<br><br>    const int length = 1024;<br>    int *arr = new int[length];<br><br>    long long b = 1000;<br>    float a = 0.0;<br><br>    // updates happen in different cache lines<br>    for (int r = 0; r &lt; b; r++) {<br>        auto start = std::chrono::high_resolution_clock::now();<br><br>        std::thread thread1(update, arr, 0);<br>        std::thread thread2(update, arr, 1);<br>        std::thread thread3(update, arr, 2);<br>        std::thread thread4(update, arr, 3);<br><br>        thread1.join();<br>        thread2.join();<br>        thread3.join();<br>        thread4.join();<br><br>        auto stop = std::chrono::high_resolution_clock::now();<br><br>        auto duration = <br>        std::chrono::duration_cast&lt;std::chrono::milliseconds&gt;<br>                    (stop - start);<br><br>        a += (float) duration.count();<br>    }<br>        <br>    std::cout &lt;&lt; float(a)/float(b) &lt;&lt; std::endl &lt;&lt; std::endl;<br><br>    return 0;<br>}</pre><p>Average time taken when updates takes place in same cache line is around 9.0 ms.</p><p>Each thread runs in a different CPU core, and as we saw earlier that each core has its own L1 and L2 cache.</p><p>The advantage of having per core L1 and L2 cache is that when working in a non-threaded environment, reads and writes are very fast as the core where the program runs can directly use its own L1 or L2 cache.</p><blockquote>But disadvantage is that with multiple threads, where each core updates its own <strong>L1 or L2 cache independently</strong>, the values needs to be <strong>in sync across all cores</strong>. Syncing takes time.</blockquote><p>For e.g. if thread A updates the integer at index i=0, then the thread B cannot update the integer at index j=1 in its own L1/L2 cache concurrently because the <strong>entire cache line is invalidated</strong> and until the update is synced across all cores, the next update cannot happen in the same cache line.</p><p>But if i, j, k and l belong to different cache lines for e.g. i=0, j=16, k=32 and l=48, then an update made by a thread <strong>does not invalidate the other cache lines</strong> and hence threads updating the other cache lines will update the integers in its own core’s cache concurrently without having to sync across all cores.</p><pre>float a = 0.0;<br>int *arr = new int[length];<br><br>// updates happen in same cache line<br>for (int r = 0; r &lt; b; r++) {<br>    auto start = std::chrono::high_resolution_clock::now();<br><br>    std::thread thread1(update, arr, 0);<br>    std::thread thread2(update, arr, 16);<br>    std::thread thread3(update, arr, 32);<br>    std::thread thread4(update, arr, 48);<br><br>    thread1.join();<br>    thread2.join();<br>    thread3.join();<br>    thread4.join();<br><br>    auto stop = std::chrono::high_resolution_clock::now();<br><br>    auto duration = <br>    std::chrono::duration_cast&lt;std::chrono::milliseconds&gt;<br>              (stop - start);<br><br>    a += (float) duration.count();<br>}<br>    <br>std::cout &lt;&lt; float(a)/float(b) &lt;&lt; std::endl &lt;&lt; std::endl;</pre><p>Average time taken when updates takes place in different cache lines is around 3.3 ms.</p><blockquote>Thus it takes almost <strong>3 times more to update integers in the same cache line as compared to different cache lines</strong> when using multiple threads.</blockquote><p><strong>Key Takeaway </strong>: Care must be taken while using multi threading with CPU bound tasks such as above as the additional costs due to <strong>context switching, creation and deletion of threads and false cache line sharing</strong> can significantly reduce the gains.</p><h3>Cache Associativity</h3><p>If we look at only the highest cache i.e. L3 cache in my system, it has 12 MB cache size and 12-way associativity. It implies that each cache line can be placed in one of 12 slots in the L3 cache.</p><p>Number of cache lines that can be placed in L3 cache = 12 MB/64 bytes = 196608.</p><p>This is the total number of slots available in the L3 cache.</p><p><strong>Since L3 is 12-ways associative, number of different 12-slots in the L3 cache = 196608/12 = 16384.</strong></p><p>For brevity, let us call a “12-slot” as a “group”.</p><p>But how to determine which cache lines goes into which group ?</p><p>Since 0 to 16383 can be represented using 14 bits, thus if we index each cache line as 0, 1, 2 … and so on then all cache lines whose lower 14 bits are the same, can be put in the same group in L3 cache. If there are more than 12 cache lines with 14 lower bits same then there will be evictions in L3 cache.</p><p>Thus all integers separated by <strong>16384*16 = 262144</strong> positions in the array would occupy the same group in L3 cache.</p><p>If there are more than 262144*12 = 3145728 integers present in the array, there will be evictions and if we try to read the evicted values, they will have to be fetched again from main memory.</p><pre>int main(int argc, char* argv[]) {<br><br>    const int length = 512*1024*1024;<br>    const int m = 262144;<br>    int *arr = new int[length];<br><br>    auto start = std::chrono::high_resolution_clock::now();<br>    for (int r = 0; r &lt; 10000; r++) {<br>        for (int i = 0; i &lt; length; i+=m) <br>            arr[i]++;<br>    }<br>    auto stop = std::chrono::high_resolution_clock::now();<br><br>    auto duration = <br>    std::chrono::duration_cast&lt;std::chrono::milliseconds&gt;(stop - start);<br>    <br>    std::cout &lt;&lt; (float) duration.count() &lt;&lt; std::endl &lt;&lt; std::endl;<br>    <br>    free(arr);<br>    return 0;<br>}</pre><blockquote>If we run the above program with values of m ranging from 262100 to 262200, we can see that the time taken for m=262144 is almost <strong>3x of that for any other value of m in that range</strong>.</blockquote><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*C0IS9Qq83_TDurxZ.png" /></figure><p>This is because for all m except for m=262144 gets <strong>distributed across multiple groups</strong> whereas for m=262144, <strong>all integers maps to the same group</strong> and hence the number of evictions in that group is very high as the number of slots is only 12 per group.</p><p>In-fact there are 512*1024*1024/262144 = 2048 different elements that maps to the same group (cache line index have same lower 14 bits).</p><p>For values of m that are multiples of 262144, they will similarly be placed in the same group.</p><p>For values of m lower than 262144 but powers of 2 such as 131072, 65536, 16384 … and so on, the integers will be distributed across multiple groups.</p><p>For e.g. with m=65536, the integers have the lower 12 bits same in cache line index and thus they will be distributed across 4 different groups (14–12=2 bits), similarly for m=16384, have lower 11 bits same in cache line index and hence distributed across 8 different groups (14–11=3 bits).</p><blockquote>Although the integers are distributed across groups but the <strong>number of cache lines mapping to the same group remains the same</strong> since we are accessing <strong>more number of cache lines with m=256 as compared to m=262144</strong>.</blockquote><blockquote>Thus for values of <strong>m which are powers of 2</strong>, there is an <strong>upward spike in running time</strong> as compared to its neighboring values.</blockquote><p><strong>Key Takeaway </strong>: When accessing elements in an array in a stepped fashion, <strong>try not to use a step size that is a power of 2</strong>.</p><h4>References</h4><ul><li><a href="https://hackmd.io/@heyfey/SJZ-3jbs5">Designing a Fast, Efficient, Cache-friendly Hash Table, Step by Step — HackMD</a></li><li><a href="https://stackoverflow.com/questions/16699247/what-is-a-cache-friendly-code">c++ — What is a “cache-friendly” code? — Stack Overflow</a></li><li><a href="https://lwn.net/Articles/255364/">Memory part 5: What programmers can do [LWN.net]</a></li><li><a href="https://www.agner.org/optimize/optimizing_cpp.pdf">Optimizing software in C++ (agner.org)</a></li><li><a href="https://web.archive.org/web/20160422113037/http://www.research.scea.com/research/pdfs/GDC2003_Memory_Optimization_18Mar03.pdf">Wayback Machine (archive.org)</a></li><li><a href="https://lwn.net/Articles/250967/">What every programmer should know about memory, Part 1 [LWN.net]</a></li><li><a href="https://stackoverflow.com/questions/3928995/how-do-cache-lines-work">memory — How do cache lines work? — Stack Overflow</a></li><li><a href="https://akkadia.org/drepper/cpumemory.pdf">What Every Programmer Should Know About Memory (akkadia.org)</a></li><li><a href="https://stackoverflow.com/questions/11227809/why-is-processing-a-sorted-array-faster-than-processing-an-unsorted-array">java — Why is processing a sorted array faster than processing an unsorted array? — Stack Overflow</a></li><li><a href="https://en.wikipedia.org/wiki/Branch_predictor">Branch predictor — Wikipedia</a></li><li><a href="https://stackoverflow.com/questions/8469427/how-and-when-to-align-to-cache-line-size">c++ — How and when to align to cache line size? — Stack Overflow</a></li><li><a href="https://www.1024cores.net/home/lock-free-algorithms/queues/bounded-mpmc-queue">1024cores — Bounded MPMC queue</a></li><li><a href="https://vorbrodt.blog/2019/02/02/cache-lines/">L1 cache lines (vorbrodt.blog)</a></li><li><a href="https://en.cppreference.com/w/cpp/thread/hardware_destructive_interference_size">std::hardware_destructive_interference_size, std::hardware_constructive_interference_size — cppreference.com</a></li><li><a href="https://cs61.seas.harvard.edu/site/2019/Storage/">Storage, Caches, and I/O — CS 61 2019 (harvard.edu)</a></li><li><a href="https://tylerayoung.com/2019/01/29/benchmarks-of-cache-friendly-data-structures-in-c/">Benchmarks of Cache-Friendly Data Structures in C++ | Tyler A. Young’s Blog (tylerayoung.com)</a></li><li><a href="https://www.microsoft.com/en-us/research/wp-content/uploads/2016/12/ccds.pdf">Untitled Document (microsoft.com)</a></li><li><a href="https://arxiv.org/pdf/1907.01631.pdf">1907.01631.pdf (arxiv.org)</a></li><li><a href="https://accu.org/journals/overload/26/146/maness_2535/">Cache-Line Aware Data Structures (accu.org)</a></li><li><a href="https://www.stardog.com/blog/writing-cache-friendly-code/">Writing cache-friendly code | Stardog</a></li><li><a href="http://wiki.dreamrunner.org/public_html/Low_Latency_Programming/low-latency-programming.html">Low Latency Programming (dreamrunner.org)</a></li><li><a href="https://johnnysswlab.com/make-your-programs-run-faster-by-better-using-the-data-cache/">Make your programs run faster by better using the data cache — Johnny’s Software Lab (johnnysswlab.com)</a></li><li><a href="https://github.com/gongyiling/FHashTable">gongyiling/FHashTable: a highly cache-friendly hash table (github.com)</a></li><li><a href="https://stackoverflow.com/questions/6630990/java-a-two-dimensional-array-is-stored-in-column-major-or-row-major-order">memory — Java: A two dimensional array is stored in column-major or row-major order? — Stack Overflow</a></li><li><a href="https://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.641.9013&amp;rep=rep1&amp;type=pdf">download (psu.edu)</a></li><li><a href="https://en.wikipedia.org/wiki/Write_amplification">Write amplification — Wikipedia</a></li><li><a href="https://colin-scott.github.io/personal_website/research/interactive_latency.html">Numbers Every Programmer Should Know By Year (colin-scott.github.io)</a></li><li><a href="https://cs61.seas.harvard.edu/site/2022/Textbook/#gsc.tab=0">Textbook — CS 61 2022 (harvard.edu)</a></li><li><a href="https://erikdemaine.org/papers/BRICS2002/paper.pdf">paper.dvi (erikdemaine.org)</a></li><li><a href="https://www.cs.utexas.edu/~pingali/CS395T/2013fa/lectures/MemoryOptimizations_2013.pdf">MemoryOptimizations_2013.pdf (utexas.edu)</a></li><li><a href="http://igoro.com/archive/gallery-of-processor-cache-effects/">Gallery of Processor Cache Effects (igoro.com)</a></li><li><a href="http://supertech.csail.mit.edu/papers/Prokop99.pdf">thesis.dvi (mit.edu)</a></li><li><a href="https://www.aristeia.com/TalkNotes/ACCU2011_CPUCaches.pdf">Microsoft PowerPoint — CPU Caches.ppt (aristeia.com)</a></li><li><a href="https://en.algorithmica.org/hpc/cpu-cache/associativity/">Cache Associativity — Algorithmica</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=810534628d71" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Fast Fourier Transform Optimizations]]></title>
            <link>https://medium.com/gaussian-machine/fast-fourier-transform-optimizations-5c1fd108a8ed?source=rss-3d860626bbc7------2</link>
            <guid isPermaLink="false">https://medium.com/p/5c1fd108a8ed</guid>
            <category><![CDATA[baileys-algorithm]]></category>
            <category><![CDATA[cache]]></category>
            <category><![CDATA[cpp]]></category>
            <category><![CDATA[fast-fourier-transform]]></category>
            <category><![CDATA[bit-reversal]]></category>
            <dc:creator><![CDATA[Abhijit Mondal]]></dc:creator>
            <pubDate>Wed, 08 Nov 2023 16:18:10 GMT</pubDate>
            <atom:updated>2023-11-08T16:18:10.735Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*uDZPkCtvZCp8BCmykK060g.jpeg" /><figcaption>source: quanta magazine</figcaption></figure><p>In the last two posts we understood the basics of fourier transform, how to speed up the DFT calculations with FFT, then looked at how we can use the FFT algorithm to solve different problems such as time series analysis, pattern matching in strings, convolution etc.</p><p>Part 1</p><p><a href="https://medium.com/@mecha-mind/an-excursion-into-fast-fourier-transform-part-1-8a6498ee0c10">An excursion into Fast Fourier Transform — The Basics and Time Series Analysis | by Abhijit Mondal | Nov, 2023 | Medium | Medium</a></p><p>Part 2</p><p><a href="https://medium.com/@mecha-mind/an-excursion-into-fast-fourier-transform-part-2-81461f125880">An excursion into Fast Fourier Transform — Pattern Matching and Other Problems | by Abhijit Mondal | Nov, 2023 | Medium | Medium</a></p><p>In this post we are going to look at ways by which we can further optimize and speed up the FFT algorithm. There could be possible hundreds of ways by which we can gain speed-up but not all strategies can give significant improvements. Here we will look at techniques which can give significant improvements (at-least 1.5x to 2x speed-ups).</p><h4>Bit Reversal</h4><p>Recall that in the FFT algorithm, for a given input sequence, we extract the elements at the even indexes and the odd indexes, do FFT on them separately and merge them. This is done recursively.</p><p>I will post the original implementation here again:</p><pre>def fft(x):<br>    n = len(x)<br>    <br>    if n == 1:<br>        return x<br><br>    # recursively do FFT on even indexes and odd indexes<br>    even_fft = fft(x[::2])<br>    odd_fft = fft(x[1::2])<br><br>    # multiplicative factor<br>    w = np.exp(-2j*np.pi/n)<br>    m = int(n/2)<br><br>    h = 1<br>    result = [0]*2*m<br>    <br>    # merge the even and odd ffts<br>    for i in range(m):<br>        result[i] = even_fft[i] + h*odd_fft[i]<br>        result[i+m] = even_fft[i] - h*odd_fft[i]<br>        h *= w<br>        <br>    return result</pre><p>Although the time complexity is O(NlogN) for an input sequence of length N, but as you know that recursion has overhead. Each recursion call, adds a new entry to the function call stack and at the end we also have to roll up the function call stack.</p><p>Also, for each recursion call, we are passing 2 new arrays of size N/2 each. If we draw the recursion tree, we can see that at each level we are allocating O(N) memory and there are O(logN) levels, thus additional memory requirement is O(NlogN).</p><blockquote><strong>Can we implement the FFT algorithm without recursion?</strong></blockquote><p>One way we can replace recursion is with stacks. This is similar to doing <strong>Depth First Traversal on a tree using stacks</strong>.</p><p>But in here too, we have to store the intermediate results of each FFT operation and also the maximum size of the stack is O(logN). Total additional memory requirement is still O(NlogN).</p><p>Instead of a top down approach, we aim to do a <strong>bottom-up approach</strong> which in this case is possible because we can reproduce the sequence at the leaf nodes of the recursion tree without going thorugh all paths from the root.</p><pre>Final sequence with N=8<br><br>Input: 0 1 2 3 4 5 6 7<br><br>          [0,1,2,3,4,5,6,7]<br>                 /\<br>                /  \<br>               /    \<br>              /      \<br>    [0,2,4,6]          [1,3,5,7]<br>       /\                 /\<br>      /  \               /  \<br>[0,4]     [2,6]     [1,5]    [3,7] <br> /\        /\        /\        /\<br>0  4      2  6      1  5      3  7<br><br><br>Final sequence : 0 4 2 6 1 5 3 7</pre><p>For a given N=8, the input sequence is [0,1,2,3,4,5,6,7]</p><p>The final sequence which we evaluate at the last level of the recursion tree is [0,4,2,6,1,5,3,7]</p><blockquote>The interesting property of the output sequence is that the binary representation of each element of the output seqence is <strong>reverse</strong> of the corresponding element in the input sequence.</blockquote><p>For e.g. the element at index 1 in input is 1 with binary 001 (3 bits), whereas the element at index 1 in output is 4 with binary 100 which is reverse of 001.</p><p>Observe that the bits are reversed for all the elements.</p><pre>Input         Output<br><br>0   000       0   000    <br>1   001       4   100<br>2   010       2   010<br>3   011       6   110<br>4   100       1   001<br>5   101       5   101<br>6   110       3   011<br>7   111       7   111</pre><blockquote>Thus we can obtain the output sequence directly from the input sequence without going through all the paths in the tree.</blockquote><p>Then we can just run a bottom-up algorithm to calculate the FFT.</p><p>Here is the python function for FFT updated to take as input, permutation of the input x according to the final sequence:</p><pre>def fft_bit_reversal(x, seq):<br>    n = len(seq)<br><br>    # seq : permutation of 0 to n-1 using bit reversal technique<br>    x = [x[i] for i in seq]<br><br>    k = 2<br>    <br>    # at each level, the even FFT lies from index i+j to i+j+u-1<br>    # and odd FFT lies from index i+j+u to i+j+k-1<br>    # merge the 2 FFTs into a single FFT from i+j to i+j+k-1<br>    while k &lt;= n:<br>        w = np.exp(-2j*np.pi/k)<br>        u = int(k/2)<br>        <br>        for i in range(0, n, k):<br>            h = 1<br>            for j in range(u):<br>                a, b = x[i+j], x[i+j+u]<br>                <br>                x[i+j] = a + h*b<br>                x[i+j+u] = a - h*b<br>                h *= w<br>                <br>        k *= 2<br><br>    return x</pre><p>Note that the time complexity of the algorithm is still <strong>O(NlogN)</strong>.</p><p>But we have not yet shown the code to calculate the final sequence using bit reversal. I wanted to deliberately keep that part outside of the fft code because the sequence permutation using bit reversal is independent of the input sequence x and is only dependent on N i.e. the size of data.</p><p>For many applications, the size N is almost fixed for many inputs like in time series analysis, the number of time intervals remains constant. But for dynamic N, we can pre-compute a lookup table of final sequences.</p><p>For e.g.</p><pre>lookup_table[8] = [0, 4, 2, 6, 1, 5, 3, 7]<br>lookup_table[16] = [0, 8, 4, 12, 2, 10, 6, 14, 1, 9, 5, 13, 3, 11, 7, 15]<br><br>and so on.</pre><p>Now we come to calculation of the lookup tables using bit reversal.</p><p>There are multiple ways we can do the calculations. In python, we can simply calculate the binary representation of integer using bin() and then reverse it and recalculate new integer using eval().</p><pre>def get_bit_reversed_seq(n, m):<br>    # m is the number of bits<br>    seq = []<br>    <br>    for x in range(n):<br>        y = bin(x)[2:]<br>        y = &#39;0&#39;*(m-len(y)) + y<br>        y_rev = y[::-1]<br>        seq += [eval(&#39;0b&#39; + y_rev)]<br><br>    return seq</pre><p>In the above ‘m’ is the number of bits for e.g. if n=16, then m=4 because all integers 0 to 15 can be represented using 4 bits: 0000 to 1111</p><p>Time complexity of the above algorithm is O(NlogN) because there are N inputs and each input has O(logN) bits. But additional space complexity is O(N). <strong>Can we come up with an in-place algorithm?</strong></p><p>To reverse a sequence, one such algorithm is to swap the adjacent elements first, then in pairs of 2, then in 4, then 8 and so on. For e.g. given a sequence x0, x1, … x15, this is how we are gonna reverse it.</p><pre><br>x0 x1 x2 x3 x4 x5 x6 x7 x8 x9 x10 x11 x12 x13 x14 x15<br><br>Swap adjacent pairs i.e. xi and xi+1 where i % 2 = 0<br><br>x1 x0 x3 x2 x5 x4 x7 x6 x9 x8 x11 x10 x13 x12 x15 x14<br><br>Then swap in batches of 2: x1 x0 with x3 x2 and so on.<br><br>x3 x2 x1 x0 x7 x6 x5 x4 x11 x10 x9 x8 x15 x14 x13 x12<br><br>Then swap in batches of 4: x1 x0 x3 x2 with x5 x4 x7 x6 and so on.<br><br>x7 x6 x5 x4 x3 x2 x1 x0 x15 x14 x13 x12 x11 x10 x9 x8<br><br>Then swap in batches of 8:<br><br>x15 x14 x13 x12 x11 x10 x9 x8 x7 x6 x5 x4 x3 x2 x1 x0<br><br>Now we have reversed the sequence</pre><p>But note that we are dealing with integer sequences and so we have to first convert them to binary representation, do bit reversal in place and then calculate new integer representation all in-place.</p><p>Instead we can use bit manipulation to achieve the same without having to convert integer to binary and then binary to integer.</p><p>The idea goes something like this (assuming that the number of bits is power of 2):</p><pre>Input x<br>Let us say that number of bits in x is 4<br><br>x = (x &amp; 0101) &lt;&lt; 1 | (x &amp; 1010) &gt;&gt; 1<br>x = (x &amp; 0011) &lt;&lt; 2 | (x &amp; 1100) &gt;&gt; 2<br><br>The final value of x will have its bits reversed.</pre><p>In the 1st step we are doing bitwise AND operation to extract the even and odd bits. Then left shift the odd bits and right shift the even bits by 1 position and then do bitwise OR. This step is similar to swapping the adjacent elements in a sequence.</p><p>In the next step, similarly we do bitwise AND but this time with pairs of 2 bits at a time. Similarly left shift and right shift by 2 bits and do bitwise OR. This step is similar to swapping pairs of 2 elements in the sequence.</p><p>The above approach is not generic for any number of bits and is shown only for 4 bits. Also it is only applicable for number of bits which is a power of 2. i.e. 1, 2, 4, 8 … and so on.</p><p>Assuming that we need at-most 32 bit sequence, we can pre-compute the sequences quite easily. For number of bits which are not a power of 2, add 0s to the right of binary representation of x to make number of bits equal to the next higher power of 2.</p><p>Here is the full python code to pre-compute the lookup tables:</p><pre>def generate_swapping_constants(n):<br>    # This function returns the constants used with bitwise AND<br>    # i.e. for extracting alternate elements, then pairs of 2, then<br>    # batches of 4 and so on.<br>    swap_constants = []<br>    <br>    i = 0<br>    while (1 &lt;&lt; i) &lt;= int(n/2):<br>        nbits = (1 &lt;&lt; i)<br>        <br>        p = &#39;0&#39;*nbits + &#39;1&#39;*nbits<br>        q = &#39;1&#39;*nbits + &#39;0&#39;*nbits<br><br>        h = len(p)<br><br>        d, r = int(n/h), n % h<br>        <br>        a = &#39;0b&#39; + p*d + p[:r]<br>        b = &#39;0b&#39; + q*d + q[:r]<br><br>        swap_constants += [(nbits, eval(a), eval(b))]<br>        i += 1<br><br>    return swap_constants<br><br>def get_bit_reversed_seq(n, m, swap_constants, nxt_power):<br>    seq = []<br>    <br>    for x in range(n):<br>        x *= (1 &lt;&lt; (nxt_power-m))<br>        <br>        for q, a, b in swap_constants:<br>            x = ((x &amp; a) &lt;&lt; q) | ((x &amp; b) &gt;&gt; q)<br>            <br>        seq += [x]<br><br>    return seq<br><br><br>max_bits = 32<br>swap_table = []<br><br>nbits = 1<br>while nbits &lt;= max_bits:<br>    swap_constants = generate_swapping_constants(nbits)<br>    swap_table += [(nbits, swap_constants)]<br>    nbits *= 2<br><br>sequences = {}<br>i = 0<br>for m in range(1, 25):<br>    n = 1 &lt;&lt; m<br><br>    while i &lt; len(swap_table) and m &gt; swap_table[i][0]:<br>        i += 1<br><br>    sequences[m] = \<br>        get_bit_reversed_seq(n, m, <br>            swap_table[i][1], swap_table[i][0])</pre><p>The ‘sequences’ is python dict with key equal to the number of bits=m and value equal to the bit reversed sequence for N=2^m.</p><p>Thus for an input sequence x, we can call the fft_bit_reversal() method as follows:</p><pre>def num_bits(n):<br>    c = 0<br>    while n &gt; 0:<br>        n = n &gt;&gt; 1<br>        c += 1<br><br>    return c<br><br>def fft_optimized(x):<br>    n = len(x)<br><br>    # n is power of 2, but sequence is from 0 to n-1<br>    # number of bits required is 1 minus the number of bits in n<br>    m = num_bits(n)-1<br><br>    return fft_bit_reversal(x, sequences[m])</pre><p>Doing a benchmark on the unoptimized FFT vs. the optimized FFT, with input sizes ranging from N=2⁸ to 2²⁰, we found that the optimized FFT is at-least 100% faster than the unoptimized FFT i.e. takes half the time to run.</p><p>Next we look into another optimization.</p><h4>Bailey’s FFT algorithm</h4><p>Till now we have been looking at Cooley-Tukey algorithm for FFT. In Bailey’s algorithm, we can optimize the calculations for parallel and distributed systems.</p><p>For this algorithm, assume that the size N is a perfect square power of 2 e.g. 4, 16, 64 etc.</p><pre>1. Layout the N elements in the input sequence in a 2D matrix of <br>   rows=sqrt(N) and cols=sqrt(N).<br><br>2. Transpose the matrix.<br><br>3. Perform FFT indepndently on each row of the matrix. This step can be<br>   distributed or done in parallel threads/processes.<br><br>4. Multiply each element at index (p, q) from step 3 with e^(-2*pi*i*p*q/N)<br><br>5. Again transpose the matrix from step 4.<br><br>6. Perform FFT indepndently on each row of the matrix. This step can be<br>   distributed or done in parallel threads/processes.<br><br>7. Transpose the matrix from step 6.</pre><p>The final matrix from step 7 is laid out in row-major order and represents the result of FFT on whole input sequence.</p><p>Here is a python implementation for Bailey’s algorithm:</p><pre>def transpose(inp, n):<br>    # transpose matrix in place<br>    for i in range(n):<br>        for j in range(i+1, n):<br>            temp = inp[j*n+i]<br>            inp[j*n+i] = inp[i*n+j]<br>            inp[i*n+j] = temp<br><br><br>def fft_bit_reversal_bailey(x, seq):<br>    # seq : permutation of 0 to sqrt(n)-1 calculated using bit reversal<br>    n = len(x)<br>    n_r = int(math.sqrt(n))<br><br>    transpose(x, n_r)<br><br>    # calculate fft on each of n_r rows using 4 processes.<br>    # due to python&#39;s GIL, using multiple threads will be of no use here.<br>    futures = []<br>    <br>    with ProcessPoolExecutor(max_workers=4) as executor:<br>        for i in range(n_r):<br>            start, end = i*n_r, (i+1)*n_r<br>            futures += [executor.submit(fft_bit_reversal2, <br>                                            x[start:end], seq, i)]<br><br>        for future in as_completed(futures):<br>            res, i = future.result()<br>            start, end = i*n_r, (i+1)*n_r<br>            x[start:end] = res<br><br>    # multiply each element at (p, q) by factor np.exp(-2j*np.pi*p*q/n)<br>    w = np.exp(-2j*np.pi/n)<br>    h1 = 1<br>    <br>    for i in range(n_r):<br>        h2 = 1<br>        for j in range(n_r):<br>            x[i*n_r + j] *= h2<br>            h2 *= h1<br>        h1 *= w<br><br>    transpose(x, n_r)<br><br>    # calculate fft on each of n_r rows using 4 processes.<br>    # due to python&#39;s GIL, using multiple threads will be of no use here.<br>    futures = []<br>    <br>    with ProcessPoolExecutor(max_workers=4) as executor:<br>        for i in range(n_r):<br>            start, end = i*n_r, (i+1)*n_r<br>            futures += [executor.submit(fft_bit_reversal2, <br>                                        x[start:end], seq, i)]<br><br>        for future in as_completed(futures):<br>            res, i = future.result()<br>            start, end = i*n_r, (i+1)*n_r<br>            x[start:end] = res<br><br>    # final transpose<br>    transpose(x, n_r)<br>    <br>    return x</pre><p>To perform FFT on each row of the matrix, we are calling a modified version of the ‘fft_bit_reversal’ routine because we are tracking for which row index the FFT is done which is used to collect the futures from concurrent.futures ProcessPoolExecutor module.</p><pre>def fft_bit_reversal2(x, seq, index):<br>    n = len(x)<br>    x = [x[i] for i in seq]<br>            <br>    k = 2<br>    <br>    while k &lt;= n:<br>        w = np.exp(-2j*np.pi/k)<br>        u = int(k/2)<br>        <br>        for i in range(0, n, k):<br>            h = 1<br>            for j in range(u):<br>                a, b = x[i+j], x[i+j+u]<br>                <br>                x[i+j] = a + h*b<br>                x[i+j+u] = a - h*b<br>                h *= w<br>                <br>        k *= 2<br><br>    return x, index</pre><p>Time complexity of Bailey’s algorithm is still O(NlogN).</p><p>Another important optimization that Bailey’s algorithm does is that since the size of each FFT is sqrt(N) instead of N, thus if sqrt(N) fits entirely in the CPU cache, then number of I/O operations using RAM will be greatly reduced and thus we can gain significant speed-ups.</p><p>The performance of the Bailey’s algorithm as compared to standard FFT algorithm (bit reversal) is not significant due to multiple factors:</p><ol><li>We are using <strong>multi-processing</strong> to run the per row FFT in parallel. <strong>Forking and cleaning up multiple processes is expensive</strong>. We cannot use multi-threading as due to <strong>Python’s GIL</strong> there will be no observable improvements for CPU bound tasks.</li><li>There are <strong>multiple transpose</strong> operations involved.</li><li>Matrix transpose operations for large matrices are generally <strong>not cache efficient </strong>as there will be large number of cache misses.</li></ol><blockquote>The multi-process Bailey’s version starts to outperform bit reversal algorithm for input sizes of greater than 2²⁰.</blockquote><p>In order to improve the cache performance of matrix transpose operation, we have seen that the following C++ version of a cache-oblivious matrix transpose generally performs 4–5x better than standard matrix transpose for input sizes beyond 1024 by 1024.</p><pre>// standard matrix transpose<br>void transpose(int *inp_arr, int *out_arr, int n, int m) {<br>    for (int i = 0; i &lt; n; i++) {<br>        for (int j = 0; j &lt; m; j++) {<br>            out_arr[j*n+i] = inp_arr[i*m+j];<br>        }<br>    }<br>}<br><br>// cache oblivious matrix transpose<br>void transpose_opt(int *inp_arr, int *out_arr, <br>        int i_start, int j_start, int i_end, int j_end, int n, int m) {<br><br>    int n1 = i_end - i_start + 1;<br>    int m1 = j_end - j_start + 1;<br><br>    // small enough matrix size fits into 64 bytes cache line<br>    if (n1*m1 &lt;= 16) {<br>        for (int i = i_start; i &lt;= i_end; i++) {<br>            for (int j = j_start; j &lt;= j_end; j++) {<br>                out_arr[j*n+i] = inp_arr[i*m+j];<br>            }<br>        }<br>    }<br>    else {<br>        // number of rows greater than number of columns then<br>        // split horizontally.<br>        if (n1 &gt;= m1) {<br>            int mid = (i_start+i_end)/2;<br><br>            transpose_opt(inp_arr, out_arr, <br>                          i_start, j_start, <br>                          mid, j_end, <br>                          n, m);<br><br>            transpose_opt(inp_arr, out_arr, <br>                          mid+1, j_start, <br>                          i_end, j_end, <br>                          n, m);<br><br>        }<br><br>        // number of columns greater than number of rows then<br>        // split vertically.<br>        else {<br>            int mid = (j_start+j_end)/2;<br><br>            transpose_opt(inp_arr, out_arr, <br>                          i_start, j_start, <br>                          i_end, mid, <br>                          n, m);<br><br>            transpose_opt(inp_arr, out_arr, <br>                          i_start, mid+1, <br>                          i_end, j_end, <br>                          n, m);<br>        }<br>    }<br>}</pre><p>Rewriting the FFT algorithm in C++ leveraging our cache oblivious matrix transpose operations:</p><pre>// in-place bit reversal FFT in C++<br>void fft_bit_reversal(std::complex&lt;double&gt; *inp, <br>                std::vector&lt;long long&gt; permutation, int n) {<br><br>    // permute the input sequence according to bit reversal<br>    for (int i = 0; i &lt; n; i++) {<br>        if (i &lt; seq[i]) {<br>            std::complex&lt;double&gt; temp = inp[i];<br>            inp[i] = inp[permutation[i]];<br>            inp[permutation[i]] = temp;<br>        }<br>    }<br><br>    int k = 2;<br>    const std::complex&lt;double&gt; i_complex(0.0,1.0);<br><br>    while (k &lt;= n) {<br>        double k_inv = 1.0/float(k);<br>        int u = k/2;<br><br>        std::complex&lt;double&gt; w = std::exp(-2.0*i_complex*M_PI*k_inv);<br><br>        for (int i = 0; i &lt; n; i += k) {<br>            std::complex&lt;double&gt; h(1.0, 0.0);<br><br>            for (int j = 0; j &lt; u; j++) {<br>                std::complex&lt;double&gt; a = inp[i+j];<br>                std::complex&lt;double&gt; b = inp[i+j+u];<br><br>                inp[i+j] = a + h*b;<br>                inp[i+j+u] = a - h*b;<br><br>                h *= w;<br>            }<br>        }<br><br>        k *= 2;<br>    }<br>}<br><br>// bailey&#39;s algorithm<br>void fft_bailey(std::complex&lt;double&gt; *inp, <br>                  std::vector&lt;long long&gt; seq, int n) {<br><br>    int n_r = int(sqrt(n));<br><br>    double n_inv = 1.0/float(n);<br>    const std::complex&lt;double&gt; i_complex(0.0,1.0);<br>    std::complex&lt;double&gt; w = std::exp(-2.0*i_complex*M_PI*n_inv);<br><br>    // 1st transpose<br>    std::complex&lt;double&gt; *out = new std::complex&lt;double&gt;[n];<br>    transpose_opt(inp, out, 0, 0, n_r-1, n_r-1, n_r, n_r);<br><br>    // do per row FFT using std::threads<br>    std::vector&lt;std::thread&gt; my_threads;<br><br>    for (int i = 0; i &lt; n_r; i++) {<br>        std::thread newThread(fft_bit_reversal, out+i*n_r, seq, n_r);<br>        my_threads.push_back(move(newThread));<br>    }<br><br>    for (int i = 0; i &lt; my_threads.size(); i++) {<br>        my_threads[i].join();<br>    }<br><br>    // multiply each element by constant<br>    std::complex&lt;double&gt; h1(1.0, 0.0);<br>    for (int i = 0; i &lt; n_r; i++) {<br>        std::complex&lt;double&gt; h2(1.0, 0.0);<br>        for (int j = 0; j &lt; n_r; j++) {<br>            out[i*n_r + j] *= h2;<br>            h2 *= h1;<br>        }<br>        h1 *= w;<br>    }<br><br>    // 2nd transpose<br>    std::complex&lt;double&gt; *out2 = new std::complex&lt;double&gt;[n];<br>    transpose_opt(out, out2, 0, 0, n_r-1, n_r-1, n_r, n_r);<br><br>    // do per row FFT using std::threads<br>    my_threads.clear();<br><br>    for (int i = 0; i &lt; n_r; i++) {<br>        std::thread newThread(fft_bit_reversal, out2+i*n_r, seq, n_r);<br>        my_threads.push_back(move(newThread));<br>    }<br><br>    for (int i = 0; i &lt; my_threads.size(); i++) {<br>        my_threads[i].join();<br>    }<br><br>    // final transpose<br>    transpose_opt(out2, inp, 0, 0, n_r-1, n_r-1, n_r, n_r);<br>}</pre><h4>Some cool references</h4><ol><li><a href="http://supertech.csail.mit.edu/papers/FrigoLePr12.pdf">obliv.dvi (mit.edu)</a></li><li><a href="https://www.ee.iitm.ac.in/~csr/teaching/pg_dsp/lecnotes/fft.pdf">Introduction to the Fast-Fourier Transform (FFT) Algorithm (iitm.ac.in)</a></li><li><a href="https://en.wikipedia.org/wiki/Cooley%E2%80%93Tukey_FFT_algorithm">Cooley–Tukey FFT algorithm — Wikipedia</a></li><li><a href="https://stackoverflow.com/questions/8587531/improving-the-speed-of-fft-implementation">c++ — Improving the speed of FFT Implementation — Stack Overflow</a></li><li><a href="https://edp.org/work/Construction.pdf">Construction of a High-Performance FFT (edp.org)</a></li><li><a href="https://eudl.eu/pdf/10.1007/978-3-642-10485-5_15">Title (eudl.eu)</a></li><li><a href="https://graphics.stanford.edu/~seander/bithacks.html">graphics.stanford.edu</a></li><li><a href="http://wgropp.cs.illinois.edu/courses/cs598-s16/lectures/lecture08.pdf">lecture08.pptx (illinois.edu)</a></li><li><a href="https://stackoverflow.com/questions/746171/efficient-algorithm-for-bit-reversal-from-msb-lsb-to-lsb-msb-in-c">Efficient Algorithm for Bit Reversal (from MSB-&gt;LSB to LSB-&gt;MSB) in C — Stack Overflow</a></li><li><a href="https://csaws.cs.technion.ac.il/~itai/Courses/Cache/matrix-transposition.pdf">matrix-transposition.pdf (technion.ac.il)</a></li><li><a href="https://wgropp.cs.illinois.edu/courses/cs598-s15/lectures/lecture07.pdf">lecture07.pptx (illinois.edu)</a></li><li><a href="https://people.csail.mit.edu/jshun/6886-s20/lectures/lecture9-1.pdf">lecture9–1.pdf (mit.edu)</a></li><li><a href="https://www.davidhbailey.com/dhbpapers/fftq.pdf">fftq.dvi (davidhbailey.com)</a></li></ol><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=5c1fd108a8ed" width="1" height="1" alt=""><hr><p><a href="https://medium.com/gaussian-machine/fast-fourier-transform-optimizations-5c1fd108a8ed">Fast Fourier Transform Optimizations</a> was originally published in <a href="https://medium.com/gaussian-machine">Gaussian Machine</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
    </channel>
</rss>