<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>amandeep singh</title><description/><link>https://amandeepsp.github.io/</link><item><title>The Boon of Dimensionality</title><link>https://amandeepsp.github.io/blog/high-dims/</link><guid isPermaLink="true">https://amandeepsp.github.io/blog/high-dims/</guid><pubDate>Tue, 03 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I recently watched Grant Sanderson&apos;s (3blue1brown) &lt;a href=&quot;https://youtu.be/fsLh-NYhOoU&quot;&gt;video&lt;/a&gt; about volume of high-dimensional spheres.
He made a note that the high-dimensional space is also of peculiar interest to the ML field. I knew of the &lt;a href=&quot;https://en.wikipedia.org/wiki/Curse_of_dimensionality&quot;&gt;&lt;em&gt;curse of dimensionality&lt;/em&gt;&lt;/a&gt;, but I wanted to dig deeper and here is what I found, the other side; the &lt;em&gt;boon of dimensionality&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;I would recommend you watch &lt;a href=&quot;https://youtu.be/fsLh-NYhOoU&quot;&gt;Grant&apos;s video&lt;/a&gt; for this. I will try my best here to explain the intuition behind the presented results.&lt;/p&gt;
&lt;h2&gt;Volume of a high-dimensional ball&lt;/h2&gt;
&lt;p&gt;The result that Grant&apos;s video centers on is that the volume of a ball with radius &lt;code&gt;r&lt;/code&gt; in &lt;code&gt;d&lt;/code&gt; dimensions is:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;V_d = \cfrac{\pi^{d/2}}{\Gamma(d/2 + 1)}{r^d}&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Where &lt;code&gt;\Gamma(x)&lt;/code&gt; is the &lt;a href=&quot;https://en.wikipedia.org/wiki/Gamma_function&quot;&gt;Gamma function&lt;/a&gt;. Plotting &lt;code&gt;V_d&lt;/code&gt; for &lt;code&gt;d&lt;/code&gt; from &lt;code&gt;1&lt;/code&gt; to &lt;code&gt;50&lt;/code&gt; we get something like this.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/blog/high-dims/volume.svg&quot; alt=&quot;Volume of a unit ball in d dimensions&quot; /&gt;&lt;/p&gt;
&lt;p&gt;You can see the volume peaks at around &lt;code&gt;d=5&lt;/code&gt;, roughly 5.26 and at &lt;code&gt;d=50&lt;/code&gt; becomes vanishingly small, since the Gamma function denominator grows much faster than the numerator. Even more strange is how this volume is distributed. Consider a ball of radius &lt;code&gt;(1-\epsilon)&lt;/code&gt; where epsilon is an infinitesimal (read: vanishingly small value). The ratio of its volume to the full ball is&lt;/p&gt;
&lt;p&gt;&lt;code&gt;  \cfrac{V(1-\epsilon)}{V(1)} = {(1-\epsilon)}^d \leq {e}^{-\epsilon d}&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Since &lt;code&gt;(1-x) \leq e^{-x}&lt;/code&gt; from Taylor expansion of &lt;code&gt;e^{-x}&lt;/code&gt;. We can see that as &lt;code&gt;d \rightarrow \infty&lt;/code&gt;, the ratio &lt;code&gt;\rightarrow 0&lt;/code&gt;. This tells us that most of the already small volume is concentrated near its surface. This is a special case of a general principle called &lt;strong&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Concentration_of_measure&quot;&gt;concentration of measure&lt;/a&gt;&lt;/strong&gt;, the tendency for high-dimensional probability distributions to concentrate their mass in thin regions.&lt;/p&gt;
&lt;h2&gt;Equators&lt;/h2&gt;
&lt;p&gt;Yet another interesting thing is about the equators, but first what is the equator of a high-dimensional ball. we can pick a coordinate say &lt;code&gt;x_1&lt;/code&gt; and all the points with &lt;code&gt;-1 \leq x_1 &amp;lt; 0&lt;/code&gt; lie in one hemisphere and &lt;code&gt; 0 &amp;lt; x_1 \leq 1&lt;/code&gt; in another; the &lt;code&gt;x_1 = 0&lt;/code&gt; boundary will be the equator. The other such slices will be &lt;code&gt;\sqrt{1 - {x_1}^2}&lt;/code&gt; (Pythagoras) and volume of such a slice will be &lt;code&gt;\propto {(1-{x_1}^2)}^{(d-1)/2} &lt;/code&gt;, since we have fixed one dimension and are left with &lt;code&gt;d-1&lt;/code&gt; dimensions. This is again bounded by&lt;/p&gt;
&lt;p&gt;&lt;code&gt;{(1-{x_1}^2)}^{(d-1)/2} \leq e^{-(d-1){x_1}^{2}/2}&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;This shows that the slice volume &lt;em&gt;decreases exponentially&lt;/em&gt; as we move
away from &lt;code&gt;0&lt;/code&gt;, which means most of the volume is also concentrated near &lt;code&gt;x_1 = 0&lt;/code&gt;. From the shell result and this we can see most of the points are concentrated on the equator.&lt;/p&gt;
&lt;h2&gt;Near Orthogonality&lt;/h2&gt;
&lt;p&gt;The value &lt;code&gt;x_1&lt;/code&gt; is just &lt;code&gt;\langle x, e_1 \rangle&lt;/code&gt;, where &lt;code&gt;e_1&lt;/code&gt; is the unit vector across the chosen axis. If most points lie near the equator (small &lt;code&gt;x_1&lt;/code&gt;), they are nearly orthogonal to &lt;code&gt;e_1&lt;/code&gt;. Since balls are rotationally symmetric, this can be said about any direction. So pick one point, make it the north pole and we are &lt;em&gt;very certain&lt;/em&gt; the next point we pick will lie on the equator.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/blog/high-dims/orthogonality.svg&quot; alt=&quot;Pairwise dot products of random unit vectors&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;The Johnson-Lindenstrauss Lemma&lt;/h2&gt;
&lt;p&gt;The near-orthogonality result has a famous companion that makes the
capacity claim precise. In 1984, Johnson and Lindenstrauss proved the
following &lt;sup&gt;&lt;a href=&quot;#user-content-fn-jl&quot;&gt;1&lt;/a&gt;&lt;/sup&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Take any &lt;code&gt;n&lt;/code&gt; points in a high-dimensional space. You can project them
into a space of just &lt;code&gt;O(\epsilon^{-2} \log n)&lt;/code&gt; dimensions and preserve all pairwise
distances up to a small factor &lt;code&gt;(1 \pm \epsilon)&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;While this is originally stated as a compression result, we can read it backwards to see that a &lt;code&gt;d&lt;/code&gt; dimensional space has the capacity to represent &lt;code&gt;e^{\Omega(d)}&lt;/code&gt; points with geometry intact. Note that this is exponential in &lt;code&gt;d&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;This is not just a theoretical curiosity. Embedding models like word2vec &lt;sup&gt;&lt;a href=&quot;#user-content-fn-w2v&quot;&gt;2&lt;/a&gt;&lt;/sup&gt; or sentence transformers &lt;sup&gt;&lt;a href=&quot;#user-content-fn-sbert&quot;&gt;3&lt;/a&gt;&lt;/sup&gt; pack millions of concepts into ~768 dimensions. The JL capacity result tells us why this works: even after accounting for the constant hidden in the &lt;code&gt;\Omega&lt;/code&gt;, a 768-dimensional space has room for a huge number of near-orthogonal directions, far more than any vocabulary needs. Unrelated words get mapped to nearly orthogonal vectors and don&apos;t interfere with each other, which is why cosine similarity between embeddings tracks semantic similarity so well. The famous analogy arithmetic (king - man + woman ≈ queen) suggests that directions in this space can be meaningful, though in practice this only works for cherry-picked examples &lt;sup&gt;&lt;a href=&quot;#user-content-fn-analogy&quot;&gt;4&lt;/a&gt;&lt;/sup&gt;. What matters more is the geometric separation. Random projections exploit this directly too: you can project high-dimensional data down to &lt;code&gt;O(\log n)&lt;/code&gt; dims via a random matrix and preserve distances, which is the basis of &lt;a href=&quot;https://en.wikipedia.org/wiki/Locality-sensitive_hashing&quot;&gt;locality-sensitive hashing&lt;/a&gt; and streaming sketches.&lt;/p&gt;
&lt;h2&gt;Where next&lt;/h2&gt;
&lt;p&gt;The curse of dimensionality tells us that data gets sparse and distances lose meaning as dimensions grow. The boon is the flip side: exponential capacity, near-orthogonality for free, and distances that survive projection. Same geometry, two readings. These two operate at different scales though: the curse applies to the &lt;em&gt;intrinsic&lt;/em&gt; dimensionality of the data (you still need samples proportional to the manifold dimension), while the boon applies to the &lt;em&gt;ambient&lt;/em&gt; dimensionality (the room you have to embed things in). ML lives in the gap since real data is high-dimensional but structured (it sits on a manifold), and the boon wins over the curse.&lt;/p&gt;
&lt;p&gt;There are two directions I want to explore from here. First is &lt;a href=&quot;https://en.wikipedia.org/wiki/Cover%27s_theorem&quot;&gt;Cover&apos;s theorem&lt;/a&gt; (1965), which says data that is not linearly separable in low dimensions becomes separable when mapped to higher dimensions. This is why kernel methods and neural network hidden layers work, they buy room. Second is &lt;a href=&quot;https://transformer-circuits.pub/2022/toy_model/index.html&quot;&gt;superposition&lt;/a&gt; in neural networks, the idea that a network with &lt;code&gt;m&lt;/code&gt; neurons can represent far more than &lt;code&gt;m&lt;/code&gt; features by packing them into near-orthogonal directions, which connects directly to the geometry above. But those are for another post.&lt;/p&gt;
&lt;section&gt;&lt;h2&gt;Footnotes&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;W. B. Johnson and J. Lindenstrauss, &quot;Extensions of Lipschitz mappings into a Hilbert space,&quot; &lt;em&gt;Contemporary Mathematics&lt;/em&gt;, 26, 1984. &lt;a href=&quot;#user-content-fnref-jl&quot;&gt;↩&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;T. Mikolov et al., &quot;Efficient Estimation of Word Representations in Vector Space,&quot; &lt;a href=&quot;https://arxiv.org/abs/1301.3781&quot;&gt;arXiv:1301.3781&lt;/a&gt;, 2013. &lt;a href=&quot;#user-content-fnref-w2v&quot;&gt;↩&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;N. Reimers and I. Gurevych, &quot;Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks,&quot; &lt;a href=&quot;https://arxiv.org/abs/1908.10084&quot;&gt;arXiv:1908.10084&lt;/a&gt;, 2019. &lt;a href=&quot;#user-content-fnref-sbert&quot;&gt;↩&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;O. Levy and Y. Goldberg, &quot;Linguistic Regularities in Sparse and Explicit Word Representations,&quot; &lt;a href=&quot;https://aclanthology.org/W14-1618/&quot;&gt;CoNLL 2014&lt;/a&gt;. See also Nissim et al., &quot;Fair is Better than Sensational,&quot; &lt;a href=&quot;https://arxiv.org/abs/1905.09866&quot;&gt;arXiv:1905.09866&lt;/a&gt;, 2019. &lt;a href=&quot;#user-content-fnref-analogy&quot;&gt;↩&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/section&gt;</content:encoded></item><item><title>Twelve Attempts at an FP4 Kernel</title><link>https://amandeepsp.github.io/blog/nvfp4-blackwell-gemv/</link><guid isPermaLink="true">https://amandeepsp.github.io/blog/nvfp4-blackwell-gemv/</guid><pubDate>Sat, 28 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I recently participated in &lt;a href=&quot;https://discord.gg/gpumode&quot;&gt;GPU Mode&apos;s&lt;/a&gt; NVFP4 Kernel Hackathon. The goal was to write CUDA kernels for Blackwell&apos;s new 4-bit floating point format (NVFP4) and get as close as possible to the hardware&apos;s speed of light. The competition ran on B200 GPUs, and there were four tasks in total: matrix-vector multiplication (GEMV), matrix-matrix multiplication (GEMM), gated GEMM (with SiLU activation), and grouped GEMM. All tasks use &lt;a href=&quot;https://docs.nvidia.com/cuda/cublas/index.html#d-block-scaling-factors-layout&quot;&gt;block-scaled FP4&lt;/a&gt; inputs where every 16 elements of &lt;code&gt;e2m1&lt;/code&gt; data share a single &lt;code&gt;e4m3&lt;/code&gt; FP8 scale factor, with &lt;code&gt;fp16&lt;/code&gt; output. I was able to complete the GEMV and GEMM tasks but ran out of time for the gated and grouped variants.&lt;/p&gt;
&lt;p&gt;If you want background on CuTe&apos;s layout algebra (which underpins a lot of what follows), I wrote about it &lt;a href=&quot;/blog/layout-algebra&quot;&gt;here&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;The Task&lt;/h2&gt;
&lt;p&gt;For GEMV we were given a matrix &lt;code&gt;A&lt;/code&gt; of shape &lt;code&gt;M \times K \times L&lt;/code&gt; and a vector &lt;code&gt;B&lt;/code&gt; of shape &lt;code&gt;1 \times K \times L&lt;/code&gt; (both in packed FP4 with FP8 scale factors), compute &lt;code&gt;C = A \cdot B^T&lt;/code&gt; of shape &lt;code&gt;M \times 1 \times L&lt;/code&gt; in FP16. The ranking metric was the geometric mean of benchmark times, measured against a speed-of-light analysis based on &lt;code&gt;max(FFMA math throughput, DRAM memory throughput)&lt;/code&gt; of the B200 at 1.5GHz clock:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;M&lt;/th&gt;
&lt;th&gt;K&lt;/th&gt;
&lt;th&gt;L&lt;/th&gt;
&lt;th&gt;Speed of Light (µs)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;7168&lt;/td&gt;
&lt;td&gt;16384&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;8.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4096&lt;/td&gt;
&lt;td&gt;7168&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;17.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7168&lt;/td&gt;
&lt;td&gt;2048&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;4.3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;Optimization Journey&lt;/h2&gt;
&lt;p&gt;This is where I spent most of my time. Twelve attempts, of which only one really worked well.&lt;/p&gt;
&lt;h3&gt;CuTe Python DSL (Attempts 1-4)&lt;/h3&gt;
&lt;p&gt;My first instinct was to use CUTLASS&apos;s Python DSL since I had been experimenting with CuTe&apos;s layout system already. The initial approach was straightforward. One thread per output row element, iterating over K, decoding FP4 to FP16, multiplying by scale factors, and accumulating.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@cute.kernel
def _kernel(self, a, b, sfa, sfb, c):
    bidx, bidy, bidz = cute.arch.block_idx()
    tidx, _, _ = cute.arch.thread_idx()

    # ... tensor setup, local_tile ...

    global_m = bidx * self.b_m + tidx
    if global_m &amp;lt; M:
        tCrC = cute.zeros_like(tCgC, cutlass.Float32)

        for k in range(k_tiles):
            # Load FP4 values, decode to FP16
            a_val = tAgA[tidx, None, k].load().to(cutlass.Float16)
            b_val = tBgB[0, None, k].load().to(cutlass.Float16)
            sfa_val = tAgSFA[tidx, None, k].load().to(cutlass.Float32)
            sfb_val = tBgSFB[0, None, k].load().to(cutlass.Float32)

            for i in cutlass.range_constexpr(self.b_k):
                tCrC += (a_val[i] * b_val[i]) * (sfa_val[i] * sfb_val[i])

        tCgC.store(tCrC.to(cute.Float16))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This produced correct results but the performance was not competitive. Each thread walks the entire K dimension sequentially with no parallelism along K. Over the next few attempts I experimented with K-dimension tiling and thread configuration but stayed within the same basic structure.&lt;/p&gt;
&lt;h3&gt;Split-K with Atomics in CuTe (Attempt 5)&lt;/h3&gt;
&lt;p&gt;To parallelize the K reduction I split the K dimension across threads and used &lt;code&gt;atomicAdd&lt;/code&gt; to accumulate partial sums. This required defining custom &lt;code&gt;@dsl_user_op&lt;/code&gt; functions for operations like &lt;code&gt;atomic_add_fp32&lt;/code&gt; that CuTe doesn&apos;t natively expose:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@dsl_user_op
def atomic_add_fp32(a, gmem_ptr, *, loc=None, ip=None):
    nvvm.atomicrmw(
        res=T.f32(), op=nvvm.AtomicOpKind.FADD,
        ptr=gmem_ptr.llvm_ptr, a=Float32(a).ir_value()
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The atomics were too expensive. The next attempt (attempt 6) replaced them with warp-shuffle reductions. 128 threads split into 4 warps, each warp handling one M row with 32-lane K tile splitting. This was better, but I was hitting a wall on the compute side.&lt;/p&gt;
&lt;p&gt;The inner loop needs packed FP16 fused multiply-add (PTX: &lt;code&gt;fma.rn.f16x2&lt;/code&gt;) to decode FP4 pairs, scale them, and accumulate efficiently. The CuTe Python DSL exposes &lt;code&gt;fma_packed_f32x2&lt;/code&gt; but has no equivalent &lt;code&gt;fma_packed_f16x2&lt;/code&gt; &lt;a href=&quot;https://github.com/NVIDIA/cutlass/blob/main/python/CuTeDSL/cutlass/cute/arch/nvvm_wrappers.py&quot;&gt;wrapper&lt;/a&gt;. Scalar FP16 arithmetic works through MLIR&apos;s &lt;code&gt;arith&lt;/code&gt; dialect (&lt;code&gt;arith.mulf&lt;/code&gt;, &lt;code&gt;arith.addf&lt;/code&gt;), but whether the backend fuses &lt;code&gt;a * b + c&lt;/code&gt; into an actual &lt;code&gt;fma.rn.f16x2&lt;/code&gt; is up to LLVM optimization passes, not something you can control or rely on. I tried writing &lt;code&gt;llvm.inline_asm&lt;/code&gt; wrappers for the &lt;code&gt;half2&lt;/code&gt; operations but couldn&apos;t get them to work, and moved on to C++.&lt;/p&gt;
&lt;p&gt;In hindsight this was a skill gap, not a DSL limitation. Another competitor placed in the top 10 (21.6µs) using a pure CuTe kernel that emits the entire decode-FMA-reduce pipeline as a single &lt;code&gt;llvm.inline_asm&lt;/code&gt; block with &lt;code&gt;cvt.rn.f16x2.e2m1x2&lt;/code&gt; and &lt;code&gt;fma.rn.f16x2&lt;/code&gt;, exactly what I was trying to do. The inlining is straightforward once you get the constraint strings right. I just didn&apos;t push through it at the time.&lt;/p&gt;
&lt;h3&gt;The Switch to Raw CUDA (Attempt 7)&lt;/h3&gt;
&lt;p&gt;I scrapped the CuTe approach and rewrote from scratch in raw CUDA C++, loaded via &lt;code&gt;torch.utils.cpp_extension.load_inline&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The core idea is to assign 32 threads (one warp) per output row. Each thread handles a strided slice of the K dimension, accumulates locally, then the warp reduces via &lt;code&gt;__shfl_down_sync&lt;/code&gt;. I used 128 threads per block with 4 rows per block.&lt;/p&gt;
&lt;p&gt;For FP4 decoding I used Blackwell&apos;s new intrinsics. Each byte packs two FP4 values, and &lt;code&gt;__nv_cvt_fp4x2_to_halfraw2&lt;/code&gt; converts them to a &lt;code&gt;half2&lt;/code&gt; pair in a single instruction&lt;sup&gt;&lt;a href=&quot;#user-content-fn-1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;__device__ __forceinline__ __half2 decode_fp4x2(uint8_t byte) {
    __half2_raw raw = __nv_cvt_fp4x2_to_halfraw2(
        static_cast&amp;lt;__nv_fp4x2_storage_t&amp;gt;(byte), __NV_E2M1
    );
    return *reinterpret_cast&amp;lt;__half2*&amp;gt;(&amp;amp;raw);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The key function is the packed &lt;code&gt;half2&lt;/code&gt; dot product over a 4-byte (8 FP4 element) chunk. This is where the real speedup came from. The rest of the kernel structure (strided K loop, warp reduction) is fairly standard, but doing the decode-scale-multiply pipeline entirely in paired &lt;code&gt;half2&lt;/code&gt; operations avoids the scalar FP32 overhead of the CuTe attempts:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;__device__ __forceinline__ __half2 dot_scaled_4bytes(
    uchar4 a4, uchar4 b4, __half2 scale_h2
) {
    __half2 acc0 = __hmul2(decode_fp4x2(a4.x),
                           __hmul2(decode_fp4x2(b4.x), scale_h2));
    __half2 acc1 = __hmul2(decode_fp4x2(a4.y),
                           __hmul2(decode_fp4x2(b4.y), scale_h2));
    acc0 = __hfma2(decode_fp4x2(a4.z),
                   __hmul2(decode_fp4x2(b4.z), scale_h2), acc0);
    acc1 = __hfma2(decode_fp4x2(a4.w),
                   __hmul2(decode_fp4x2(b4.w), scale_h2), acc1);
    return __hadd2(acc0, acc1);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each scale factor group covers 16 FP4 elements = 8 bytes. The outer loop loads two &lt;code&gt;uchar4&lt;/code&gt; reads per group (4 bytes each, 8 FP4 values per load), calls &lt;code&gt;dot_scaled_4bytes&lt;/code&gt; on each, and accumulates into FP32:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#pragma unroll 4
for (int sf = tid; sf &amp;lt; K_sf; sf += THREADS_PER_ROW) {
    float scale = decode_fp8(__ldg(&amp;amp;row_sfa[sf])) *
                  decode_fp8(__ldg(&amp;amp;batch_sfb[sf]));
    __half2 scale_h2 = __halves2half2(__float2half(scale),
                                       __float2half(scale));

    int byte_base = sf &amp;lt;&amp;lt; 3;
    uchar4 a4_0 = *reinterpret_cast&amp;lt;const uchar4*&amp;gt;(&amp;amp;row_a[byte_base]);
    uchar4 b4_0 = *reinterpret_cast&amp;lt;const uchar4*&amp;gt;(&amp;amp;batch_b[byte_base]);
    uchar4 a4_1 = *reinterpret_cast&amp;lt;const uchar4*&amp;gt;(&amp;amp;row_a[byte_base + 4]);
    uchar4 b4_1 = *reinterpret_cast&amp;lt;const uchar4*&amp;gt;(&amp;amp;batch_b[byte_base + 4]);

    __half2 r0 = dot_scaled_4bytes(a4_0, b4_0, scale_h2);
    __half2 r1 = dot_scaled_4bytes(a4_1, b4_1, scale_h2);
    __half2 sum = __hadd2(r0, r1);
    float2 f = __half22float2(sum);
    acc += f.x + f.y;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After accumulation, the warp reduces with shuffles:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;row_sum += __shfl_down_sync(0xffffffff, row_sum, 16);
row_sum += __shfl_down_sync(0xffffffff, row_sum, 8);
row_sum += __shfl_down_sync(0xffffffff, row_sum, 4);
row_sum += __shfl_down_sync(0xffffffff, row_sum, 2);
row_sum += __shfl_down_sync(0xffffffff, row_sum, 1);

if (lane == 0) {
    c[c_idx] = __float2half(row_sum);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Results:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;M&lt;/th&gt;
&lt;th&gt;K&lt;/th&gt;
&lt;th&gt;L&lt;/th&gt;
&lt;th&gt;My kernel (µs)&lt;/th&gt;
&lt;th&gt;Speed of Light (µs)&lt;/th&gt;
&lt;th&gt;Ratio&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;7168&lt;/td&gt;
&lt;td&gt;16384&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;26.7&lt;/td&gt;
&lt;td&gt;8.6&lt;/td&gt;
&lt;td&gt;3.1x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4096&lt;/td&gt;
&lt;td&gt;7168&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;45.1&lt;/td&gt;
&lt;td&gt;17.3&lt;/td&gt;
&lt;td&gt;2.6x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7168&lt;/td&gt;
&lt;td&gt;2048&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;16.4&lt;/td&gt;
&lt;td&gt;4.3&lt;/td&gt;
&lt;td&gt;3.8x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Roughly 3x off speed of light. This became my baseline.&lt;/p&gt;
&lt;h3&gt;The Failed Experiments&lt;/h3&gt;
&lt;p&gt;Over the next five attempts, I tried every optimization I could think of. All of them made things worse or had zero effect.&lt;/p&gt;
&lt;p&gt;In attempt 8, I went back to split-K with atomics, this time in C++. I tiled the K dimension across blocks and used &lt;code&gt;atomicAdd&lt;/code&gt; to accumulate partial sums into an FP32 intermediate buffer with a final FP32-to-FP16 conversion pass. The atomic contention and extra memory traffic outweighed any parallelism benefit. The kernel is memory-bound, and adding more blocks just means more scheduling overhead and more atomic serialization at the same memory addresses.&lt;/p&gt;
&lt;p&gt;Attempt 9 tried wider vectorized loads. Instead of two &lt;code&gt;uchar4&lt;/code&gt; loads (32 bits each), I used a single &lt;code&gt;uint2&lt;/code&gt; load (64 bits). This was 16-25% slower. Extracting individual bytes from a &lt;code&gt;uint2&lt;/code&gt; requires bitwise operations (&lt;code&gt;&amp;amp; 0xFF&lt;/code&gt;, &lt;code&gt;&amp;gt;&amp;gt; 8&lt;/code&gt;, etc.) and &lt;code&gt;make_uchar4()&lt;/code&gt; calls. The compiler already optimizes two consecutive &lt;code&gt;uchar4&lt;/code&gt; loads into efficient memory transactions. I was adding instruction overhead to &quot;save&quot; an instruction the compiler was already handling.&lt;/p&gt;
&lt;p&gt;Attempt 10 was the worst regression at +32-55%. I tried four independent accumulator chains to hide FMA latency through instruction-level parallelism. But the kernel is &lt;em&gt;memory-bound&lt;/em&gt;, not compute-bound. FMA latency hiding is irrelevant when every cycle is waiting on memory. The four accumulator chains increased register pressure enough to cause spilling, and the strided K access pattern (&lt;code&gt;THREADS_PER_ROW * 4&lt;/code&gt; instead of &lt;code&gt;THREADS_PER_ROW&lt;/code&gt;) reduced memory coalescing. I was optimizing for the wrong bottleneck.&lt;/p&gt;
&lt;p&gt;In attempt 11 I tried tuning register count and block size. Reducing &lt;code&gt;-maxrregcount&lt;/code&gt; from 80 to 64 had zero effect since the kernel naturally uses fewer than 64 registers. &lt;code&gt;BLOCK_SIZE=256&lt;/code&gt; with &lt;code&gt;ROWS_PER_BLOCK=8&lt;/code&gt; also changed nothing, because the 32-threads-per-row warp structure is what matters, not the block size. Bumping to &lt;code&gt;#pragma unroll 8&lt;/code&gt; instead of &lt;code&gt;unroll 4&lt;/code&gt; dropped performance by 5-87% due to register pressure and I-cache misses.&lt;/p&gt;
&lt;p&gt;Finally, attempt 12 tried software pipelining with an explicit prologue to prefetch the next K tile into registers while computing the current one. On the B200&apos;s memory subsystem, the hardware prefetcher combined with &lt;code&gt;__ldg&lt;/code&gt; cache hints is already doing this. Manual pipelining just doubled register pressure for data that was already on its way.&lt;/p&gt;
&lt;h3&gt;Why 3x Off?&lt;/h3&gt;
&lt;p&gt;The kernel is fundamentally memory-bound. FP4 data is tiny (4 bits per element), and even with block scaling overhead, the arithmetic intensity is low for GEMV. The speed-of-light analysis is based on DRAM bandwidth limits. Even the top solutions only reached ~2x off speed of light. The gap from 2x to 1x is genuinely hard for GEMV and may require approaches beyond what any competitor used.&lt;/p&gt;
&lt;p&gt;The gap between my 3x and the winners&apos; 2x came down to execution details I cover in the next section: cache policies, load widths, register pressure, and compile-time specialization. I didn&apos;t invest enough time into understanding the Nsight Compute profile for my kernel. Had I done that after attempt 7, it would have told me immediately that the kernel was memory-bound and that compute-side optimizations (ILP, wider instructions) were pointless. I instead learned this the hard way through attempts 8-12.&lt;/p&gt;
&lt;h2&gt;What the Top Solutions Did Differently&lt;/h2&gt;
&lt;p&gt;After the hackathon ended I studied the top 3 solutions (all clustered around 18.5µs geometric mean, roughly 2x speed of light vs my 3x). They shared several techniques I hadn&apos;t used.&lt;/p&gt;
&lt;p&gt;All three wrote their load and decode paths in raw PTX rather than using C intrinsics. Where I used &lt;code&gt;__nv_cvt_fp4x2_to_halfraw2&lt;/code&gt;, they wrote &lt;code&gt;cvt.rn.f16x2.e2m1x2&lt;/code&gt; directly. Where I used &lt;code&gt;__ldg&lt;/code&gt;, they wrote &lt;code&gt;ld.global&lt;/code&gt; with explicit qualifiers. This gives precise control over instruction selection and scheduling that the C intrinsics abstract away.&lt;/p&gt;
&lt;p&gt;The biggest gap was cache policy control. All top solutions used different cache hints for A (the matrix, streamed once) vs B (the vector, reused across all rows). For A they used &lt;code&gt;L1::no_allocate&lt;/code&gt; to avoid polluting L1 with data that won&apos;t be reused. For B they used &lt;code&gt;L1::evict_last&lt;/code&gt; to keep it hot in cache since every row reads the same vector. My &lt;code&gt;__ldg&lt;/code&gt; just requests a generic read-only cache path with no distinction between streaming and reusable data.&lt;/p&gt;
&lt;p&gt;They also used much wider vectorized loads. I was loading with &lt;code&gt;uchar4&lt;/code&gt; (32-bit). The top solutions loaded with &lt;code&gt;ld.global.v2.u64&lt;/code&gt; (128-bit) and even &lt;code&gt;ld.global.v4.u64&lt;/code&gt; (256-bit), fetching 32 or 64 FP4 values in a single memory transaction. This is the kind of wider load that actually works. Unlike my attempt 9 with &lt;code&gt;uint2&lt;/code&gt;, the top solutions decoded using PTX byte unpacking (&lt;code&gt;mov.b32 {tmp0, tmp1, tmp2, tmp3}, %reg&lt;/code&gt;) which avoids the bitwise extraction overhead that killed my approach.&lt;/p&gt;
&lt;p&gt;Rather than one generic kernel, the top solutions templated on the exact K dimension and dispatched at launch time. This lets the compiler fully unroll the K loop with a known trip count and select optimal register allocation per problem size. The rank 1 solution went further with per-K cache hint tuning, using different &lt;code&gt;ld.global&lt;/code&gt; qualifiers for K=3584, K=8192, and K=1024.&lt;/p&gt;
&lt;p&gt;Register budgets were also much tighter. The rank 1 solution used &lt;code&gt;-maxrregcount=32&lt;/code&gt;, rank 3 used 45. I used 80. Lower register counts increase occupancy (more warps in flight), which is what a memory-bound kernel needs to hide memory latency. My attempt 11 tested this but only went down to 64, not aggressive enough.&lt;/p&gt;
&lt;p&gt;The rank 2 solution also processed &lt;code&gt;BLOCK_M&lt;/code&gt; rows per thread block where threads reading B data are shared across rows. This amortizes the B vector load cost. My kernel loaded B independently per warp, wasting bandwidth on redundant reads of the same vector.&lt;/p&gt;
&lt;p&gt;One thing that surprised me; a pure PyTorch solution using &lt;code&gt;torch._scaled_mm&lt;/code&gt; with multi-stream parallelism across the L dimension scored 22.4µs. No custom kernels at all, just calling into cuBLAS&apos;s FP4 path with the right scale factor layout. That&apos;s within 20% of the top 3 PTX solutions and faster than my hand-written C++ kernel.&lt;/p&gt;
&lt;h2&gt;Takeaways&lt;/h2&gt;
&lt;p&gt;The single most important thing I could have done after attempt 7 was run Nsight Compute and confirm the kernel was memory-bound. That would have saved me from attempts 8-12. Instead I optimized based on intuition, and intuition was wrong. Split-K doesn&apos;t help memory-bound kernels. Wider loads only help when data can be used directly without unpacking. ILP is irrelevant when the bottleneck is memory. Register tuning does nothing if you&apos;re already under the limit. Software pipelining is redundant when hardware prefetch is sufficient.&lt;/p&gt;
&lt;p&gt;The full source code for all attempts is available on &lt;a href=&quot;https://github.com/amandeepsp/cuda&quot;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;
&lt;section&gt;&lt;h2&gt;Footnotes&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;At the PTX level this maps to &lt;code&gt;cvt.rn.f16x2.e2m1x2&lt;/code&gt;, which is a single instruction. However, the C intrinsic doesn&apos;t always compile down to it cleanly. The rank 2 hackathon winner noted that &lt;code&gt;__nv_cvt_fp4x2_to_halfraw2()&lt;/code&gt; didn&apos;t produce the desired PTX, which is one reason all top solutions wrote inline PTX directly. &lt;a href=&quot;#user-content-fnref-1&quot;&gt;↩&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/section&gt;</content:encoded></item><item><title>Honey, I Tiled the Tensors</title><link>https://amandeepsp.github.io/blog/layout-algebra/</link><guid isPermaLink="true">https://amandeepsp.github.io/blog/layout-algebra/</guid><pubDate>Thu, 26 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import Figure from &quot;@/components/Figure.astro&quot;;&lt;/p&gt;
&lt;p&gt;Layouts are a powerful abstraction introduced in NVIDIA&apos;s &lt;a href=&quot;https://docs.nvidia.com/cutlass/latest/media/docs/cpp/cute/00_quickstart.html&quot;&gt;CuTe library&lt;/a&gt; for making operations on complicated Tensor configurations a little bit easier to understand.
My goal here is to provide a good taste of how operations on these layouts work and an example matrix-matrix multiplication kernel to show the value of these abstractions and its drawbacks. This is not a full mathematical analysis of Layouts, for that a good reference is the official paper by CuTe&apos;s creator Cris Cecka, &lt;a href=&quot;https://arxiv.org/abs/2603.02298&quot;&gt;&quot;CuTe Layout Representation and Algebra&quot;&lt;/a&gt;, or the earlier community note &lt;a href=&quot;https://research.colfax-intl.com/wp-content/uploads/2024/01/layout_algebra.pdf&quot;&gt;&quot;A note on the algebra of CuTe Layouts&quot;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;GPUs don&apos;t know about tensors and their structures, operations are only performed on linear memory. Tensor structures need to be maintained by the kernels themselves. e.g. for a row-major matrix the matrix indices &lt;code&gt;i&lt;/code&gt;, &lt;code&gt;j&lt;/code&gt; will become index &lt;code&gt;i*C + j&lt;/code&gt; where &lt;code&gt;C&lt;/code&gt; is the number of columns. These kinds of mappings are pivotal in building GPU kernels since the shapes and structures of tensors can get very complicated.&lt;/p&gt;
&lt;p&gt;A Layout we can easily define as nothing but a combination of the shape and stride (num of jumps to go from one element to next in a dimension) of a Tensor. It defines a mapping from the Tensor coordinate space to a flat array layout indices. Here stride and shape are tuples of matching dimensions. Calling the shapes &lt;code&gt;S&lt;/code&gt; and the strides &lt;code&gt;D&lt;/code&gt; we say&lt;/p&gt;
&lt;p&gt;&lt;code&gt;L = S : D&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;For example lets start with matrices, a row major &lt;code&gt;n \times m&lt;/code&gt; matrix will be represented as &lt;code&gt;(n, m) : (m, 1)&lt;/code&gt;.
Here we can see that increasing the first dimension (row) coordinate by &lt;code&gt;1&lt;/code&gt; will increase the flat index by &lt;code&gt;m&lt;/code&gt; and the second dimension (column) only increases it by &lt;code&gt;1&lt;/code&gt;. For getting the flat index/offset given &lt;code&gt;i&lt;/code&gt;, &lt;code&gt;j&lt;/code&gt;, we know we can get it by &lt;code&gt;i \times m + j = i \times m + j \times 1&lt;/code&gt;. Or more generally we can see from how stride is defined that the flat index &lt;code&gt;\text{offset}&lt;/code&gt; given indices &lt;code&gt;i = \{ i_0, i_1, ..., i_d\}&lt;/code&gt; and stride &lt;code&gt;s = \{s_0, s_1, ..., s_d\}&lt;/code&gt;,&lt;/p&gt;
&lt;p&gt;&lt;code&gt;I = \sum i_k s_k  = i \cdot s&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Similarly the column-major matrix represented as &lt;code&gt;(n,m):(1,m)&lt;/code&gt;. Layouts can themselves be again made out of other Layouts. Lets analyze an example; &lt;code&gt;((2,2), (2,2)): ((1,4), (2,8))&lt;/code&gt;. Okay! Lets try some coordinates.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Coordinate &lt;code&gt;((i_0,i_1),(j_0,j_1))&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Calculation&lt;/th&gt;
&lt;th&gt;Physical Offset&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;((0,0), (0,0))&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0(1) + 0(4) + 0(2) + 0(8)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;((0,1), (0,0))&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0(1) + 1(4) + 0(2) + 0(8)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;((1,0), (0,0))&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1(1) + 0(4) + 0(2) + 0(8)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;((1,1), (0,0))&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1(1) + 1(4) + 0(2) + 0(8)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;((0,0), (1,0))&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0(1) + 0(4) + 1(2) + 0(8)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;((0,1), (1,0))&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0(1) + 1(4) + 1(2) + 0(8)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;((1,0), (1,0))&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1(1) + 0(4) + 1(2) + 0(8)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;((1,1), (1,0))&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1(1) + 1(4) + 1(2) + 0(8)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;As we can see this is an interleaved layout (or swizzled). These kinds of layouts are generally used in GPU kernels to prevent memory bank conflicts.&lt;/p&gt;
&lt;p&gt;This is okay, but it is inconvenient to do offset calculations of a particularly complex layout. Another way of visualizing this is to think about the groups individually first &lt;code&gt;(2, 2)&lt;/code&gt; has a stride of &lt;code&gt;(1, 4)&lt;/code&gt; creating a &lt;code&gt;0 \rightarrow 4 \rightarrow 1 \rightarrow 5&lt;/code&gt; and this is repeated again in a &lt;code&gt;(2,2)&lt;/code&gt; shape with each block in a &lt;code&gt; 0 \rightarrow 2 \rightarrow 1 \rightarrow 3&lt;/code&gt; layout. This can be seen by factorizing the expanded &lt;code&gt;\text{offset}&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;\text{offset} = i_0 \cdot 1 + i_1 \cdot 4 + j_0 \cdot 2 + j_1 \cdot 8&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;\text{offset} = \underbrace{(i_0 + 2 \cdot j_0)}_{r} \cdot 1 + \underbrace{(i_1 + 2 \cdot j_1)}_{c} \cdot 4&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;This reduces a 4D layout into a 2D layout &lt;code&gt;(4, 4) : (1, 4)&lt;/code&gt; which is easy to visualize. But this is not possible for all layouts.&lt;/p&gt;
&lt;p&gt;Layouts can also be operated on. This is the algebra part of the layout algebra. So we can have functions that can map from one layout to another layout. Such a function is equivalent to mapping of one flat index space to another. Hence&lt;/p&gt;
&lt;p&gt;&lt;code&gt;f : L_1 \rightarrow L_2  \implies f(i) : I(L_1) \rightarrow I(L_2)&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Lets go through a few of these operations.&lt;/p&gt;
&lt;h2&gt;Coalescing&lt;/h2&gt;
&lt;p&gt;Coalescing is to simplify a layout. More formally, we can define &lt;em&gt;Rank&lt;/em&gt; of a layout as the number of modes it has, coalesce operation reduces the number of modes i.e. to reduce its Rank. For example the layout &lt;code&gt;(2,4):(1, 2)&lt;/code&gt; is the same as &lt;code&gt;8:1&lt;/code&gt; for 1-D coordinates. So how do we get a coalesced layout? Say we have a layout &lt;code&gt;L = (s_0, s_1, ...) : (d_0, d_1, ...) &lt;/code&gt;, (no nesting) how do we reduce a pair of layouts into one layout? We can check if the two modes are contiguous. When are they contiguous? When the next layout has a stride equaling the total flat indices of the former layout. This way they line up perfectly. so for &lt;code&gt;s_0: d_0&lt;/code&gt; and &lt;code&gt;s_1:d_1&lt;/code&gt; can reduce iff.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;d_1 = s_0 \times d_0&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;and the two modes merge into &lt;code&gt;s_0 \times s_1 : d_0&lt;/code&gt;. We can also see that &lt;code&gt;1&lt;/code&gt; shape is an identity in reduction. i.e. &lt;code&gt;1: d&apos;&lt;/code&gt; and &lt;code&gt;s: d&lt;/code&gt; reduce to &lt;code&gt;s: d&lt;/code&gt;. This is useful when we need to simplify some particularly gnarly tensor layouts.
Coalescing can also be done by-mode i.e. we can only reduce some ranks e.g. from &lt;code&gt;4 \rightarrow 2&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;Composition&lt;/h2&gt;
&lt;p&gt;Composition chains two layouts together. Given a layout &lt;code&gt;A&lt;/code&gt; and a layout &lt;code&gt;B&lt;/code&gt;, the composition &lt;code&gt;B \circ A&lt;/code&gt; creates a new layout that first maps coordinates through &lt;code&gt;A&lt;/code&gt; and then uses those resulting indices as coordinates into &lt;code&gt;B&lt;/code&gt;. In other words, the output indices of &lt;code&gt;A&lt;/code&gt; become the input coordinates of &lt;code&gt;B&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;(B \circ A)(i) = B(A(i))&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Why is this useful? Composition is fundamental to the concept of tiling, where we partition a large data into smaller chunks that can be loaded into GPU&apos;s Shared Memory or further into Register Memory. We can do this with the composition operation as we&apos;ll see in later sections.&lt;/p&gt;
&lt;p&gt;Lets work through an example. Take &lt;code&gt;A = (3, 2) : (1, 3)&lt;/code&gt; and &lt;code&gt;B = 6 : 2&lt;/code&gt;. Layout &lt;code&gt;A&lt;/code&gt; maps 2-D coordinates to 1-D indices in a &lt;code&gt;3 \times 2&lt;/code&gt; arrangement. Layout &lt;code&gt;B&lt;/code&gt; maps a 1-D coordinate to offsets &lt;code&gt;\{0, 2, 4, 6, 8, 10\}&lt;/code&gt;. The composition &lt;code&gt;B \circ A&lt;/code&gt; should give us a layout that maps 2-D coordinates directly to the physical offsets.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Coordinate &lt;code&gt;(i, j)&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;A(i,j)&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;B(A(i,j))&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;(0, 0)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;(1, 0)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;2&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;(2, 0)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;4&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;(0, 1)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;6&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;(1, 1)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;4&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;8&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;(2, 1)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;5&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;10&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;We can verify this is the layout &lt;code&gt;(3, 2) : (2, 6)&lt;/code&gt;. Now lets build up the general composition rules.&lt;/p&gt;
&lt;h3&gt;Single-mode &lt;code&gt;B&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;In the simple case where &lt;code&gt;B = s_B : d_B&lt;/code&gt; is a single mode, composition just scales the strides of &lt;code&gt;A&lt;/code&gt; by &lt;code&gt;d_B&lt;/code&gt;:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;(s_B : d_B) \circ (S_A : D_A) = S_A : (d_B \cdot D_A)&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;This works because &lt;code&gt;B&lt;/code&gt; is linear — &lt;code&gt;B(k) = k \cdot d_B&lt;/code&gt; — so &lt;code&gt;B(A(i)) = d_B \cdot A(i)&lt;/code&gt;. This is the case we saw in the example above: &lt;code&gt;(6:2) \circ ((3,2):(1,3)) = (3,2):(2,6)&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;Single-mode &lt;code&gt;A&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;When &lt;code&gt;B&lt;/code&gt; has multiple modes, the output of &lt;code&gt;A&lt;/code&gt; needs to be decomposed into coordinates for &lt;code&gt;B&lt;/code&gt;. Given &lt;code&gt;B = (s_0, s_1, ...) : (d_0, d_1, ...)&lt;/code&gt; and &lt;code&gt;A = s : d&lt;/code&gt;, the flat indices from &lt;code&gt;A&lt;/code&gt; (which are &lt;code&gt;\{0, d, 2d, ..., (s-1)d\}&lt;/code&gt;) are split across &lt;code&gt;B&lt;/code&gt;&apos;s modes using its shapes. This can only be done if either &lt;code&gt;d \mid s_0&lt;/code&gt; or &lt;code&gt;s_0 \mid d&lt;/code&gt;. Without this, &lt;code&gt;A&lt;/code&gt;&apos;s indices don&apos;t cleanly align with &lt;code&gt;B&lt;/code&gt;&apos;s mode boundaries and composition is undefined.&lt;/p&gt;
&lt;p&gt;There are two cases:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Case 1: &lt;code&gt;s_0 \mid d&lt;/code&gt; (stride skips past first mode).&lt;/strong&gt; Since &lt;code&gt;d&lt;/code&gt; is a multiple of &lt;code&gt;s_0&lt;/code&gt;, every index &lt;code&gt;k \cdot d&lt;/code&gt; has &lt;code&gt;k \cdot d \mod s_0 = 0&lt;/code&gt;, so &lt;code&gt;B&lt;/code&gt;&apos;s first mode coordinate is always zero. We skip it entirely and recurse with reduced stride:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;(s_0, s_1, ...) : (d_0, d_1, ...) \circ (s : d) = (s_1, ...) : (d_1, ...) \circ (s : d / s_0)&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Case 2: &lt;code&gt;d \mid s_0&lt;/code&gt; (stride fits within first mode).&lt;/strong&gt; Let &lt;code&gt;q = s_0 / d&lt;/code&gt;. The first &lt;code&gt;q&lt;/code&gt; indices from &lt;code&gt;A&lt;/code&gt; cycle through &lt;code&gt;B&lt;/code&gt;&apos;s first mode before overflowing into the next.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;If &lt;code&gt;s \leq q&lt;/code&gt;: all indices fit in the first mode. Result: &lt;code&gt;s : (d_0 \cdot d)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;If &lt;code&gt;s &amp;gt; q&lt;/code&gt; and &lt;code&gt;q \mid s&lt;/code&gt;: the first mode fills completely, and the rest recurse:
&lt;code&gt;B \circ (s : d) = (q : d_0 \cdot d),\ ((s_1, ...) : (d_1, ...) \circ (s / q : 1))&lt;/code&gt; &lt;sup&gt;&lt;a href=&quot;#user-content-fn-1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Lets trace through a concrete example. Take &lt;code&gt;B = (4, 3) : (1, 8)&lt;/code&gt; and &lt;code&gt;A = 6 : 2&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;We have &lt;code&gt;d = 2&lt;/code&gt; and &lt;code&gt;s_0 = 4&lt;/code&gt;. Since &lt;code&gt;d \mid s_0&lt;/code&gt;, we&apos;re in Case 2 with &lt;code&gt;q = 4 / 2 = 2&lt;/code&gt;. Since &lt;code&gt;s = 6 &amp;gt; q = 2&lt;/code&gt; and &lt;code&gt;q \mid s&lt;/code&gt;:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;(4, 3) : (1, 8) \circ (6 : 2) = (2 : 1 \cdot 2),\ ((3,) : (8,) \circ (3 : 1))&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;For the recursive step, &lt;code&gt;d = 1&lt;/code&gt; and &lt;code&gt;s_0 = 3&lt;/code&gt;, so &lt;code&gt;q = 3&lt;/code&gt; and &lt;code&gt;s = 3 \leq q&lt;/code&gt;:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;(3,) : (8,) \circ (3 : 1) = 3 : 8&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Combining: &lt;code&gt;B \circ A = (2, 3) : (2, 8)&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;General composition: left-associative reduction&lt;/h3&gt;
&lt;p&gt;For the fully general case where both &lt;code&gt;A&lt;/code&gt; and &lt;code&gt;B&lt;/code&gt; are multi-mode, we reduce it to the cases above by processing &lt;code&gt;A&lt;/code&gt;&apos;s modes left to right. Given &lt;code&gt;A = (s_0^A, s_1^A, ...) : (d_0^A, d_1^A, ...)&lt;/code&gt;, we compose each mode of &lt;code&gt;A&lt;/code&gt; one at a time (due to &lt;sup&gt;&lt;a href=&quot;#user-content-fn-1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt;):&lt;/p&gt;
&lt;p&gt;&lt;code&gt;B \circ A = (B \circ (s_0^A : d_0^A)),\ (B_\text{rest} \circ (s_1^A : d_1^A)),\ ...&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Each single-mode composition consumes some of &lt;code&gt;B&lt;/code&gt;&apos;s leading modes (via the cases above), and the unconsumed remainder &lt;code&gt;B_\text{rest}&lt;/code&gt; carries forward for the next mode of &lt;code&gt;A&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Composition of a Layout with a smaller Tile layout gives us a single first Tile from the original layout, we need some operation that can also do this for the rest of the layout.&lt;/p&gt;
&lt;h2&gt;Complement&lt;/h2&gt;
&lt;p&gt;Composition is always a subset of indices from the index space of &lt;code&gt;A&lt;/code&gt;, what about the leftovers? This is where the complement operation comes into the picture. Complement is done with respect to a size &lt;code&gt;M&lt;/code&gt;, i.e. given a layout &lt;code&gt;A&lt;/code&gt; the complement &lt;code&gt;A^*_M&lt;/code&gt;, is the layout that fills up the rest of &lt;code&gt;M&lt;/code&gt; indices not covered by &lt;code&gt;A&lt;/code&gt;. Complement only makes sense if the gaps left in &lt;code&gt;M&lt;/code&gt; indices is shaped for filling up the space perfectly with a layout. For a layout &lt;code&gt;A = (s_0, s_1, ..., s_n) : (d_0, d_1, ..., d_n)&lt;/code&gt;, this means &lt;code&gt;s_i \cdot d_i | d_{i+1}&lt;/code&gt; and &lt;code&gt;s_n \cdot d_n | M&lt;/code&gt;. This means that the inner strides should fit into outer strides. We can calculate the complement of &lt;code&gt;A&lt;/code&gt; as.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;A^*_M = (d_0, \frac{d_1}{s_0 \cdot d_0}, \frac{d_2}{s_1 \cdot d_1}, ...,  \frac{M}{s_n \cdot d_n}) : (1, s_0 \cdot d_0, s_1 \cdot d_1, ..., s_n \cdot d_n)&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Here is an example for complement. Note we can reduce the &lt;code&gt;s_0: d_0 = 1:1&lt;/code&gt; from this example. The gray part is the original layout and colored indices&lt;/p&gt;
&lt;h2&gt;Division&lt;/h2&gt;
&lt;p&gt;Division operation splits a layout &lt;code&gt;B&lt;/code&gt; into equal-sized tiles defined by layout &lt;code&gt;A&lt;/code&gt;. As we have seen previously we can compose a layout and a tiler to get the first tile but to get all of them we need to also compose the complement with respect to a size &lt;code&gt;M&lt;/code&gt; to get the rest of them.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;B \oslash A := B \circ (A, A^*_M)&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;This splits each mode of &lt;code&gt;B&lt;/code&gt; into two: the first mode is &lt;em&gt;within&lt;/em&gt; tile from the composition and the second mode is indexing &lt;em&gt;across&lt;/em&gt; tile from the complement. For a mode of &lt;code&gt;B&lt;/code&gt; with shape &lt;code&gt;s_B&lt;/code&gt; and stride &lt;code&gt;d_B&lt;/code&gt;, and tiler shape &lt;code&gt;t&lt;/code&gt;, we get&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Within-tile: shape &lt;code&gt;t&lt;/code&gt;, stride &lt;code&gt;d_B&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Across-tiles: shape &lt;code&gt;s_B / t&lt;/code&gt; and stride &lt;code&gt;t \cdot d_B&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The original mode becomes a pair: &lt;code&gt;(t, s_B / t): (d_B, t \cdot d_B)&lt;/code&gt;. This operation doubles the &lt;em&gt;Rank&lt;/em&gt; of the layout. Lets work through a few examples, first a simple 1D case. &lt;code&gt;B = 12:1&lt;/code&gt; and &lt;code&gt;A = 4:1&lt;/code&gt;, &lt;code&gt;A&lt;/code&gt; should divide &lt;code&gt;B&lt;/code&gt; into 3 pieces. Hence;&lt;/p&gt;
&lt;p&gt;Complement: &lt;code&gt;A^*_{12} = 3:4&lt;/code&gt; (the 3 tile offsets: &lt;code&gt;{0, 4, 8}&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;Concatenating: &lt;code&gt;(A, A^*) = (4, 3):(1, 4)&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Composing: &lt;code&gt;B \circ (4,3):(1,4) = (4, 3):(1, 4)&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The first mode (size 4, stride 1) = within-tile indices &lt;code&gt;{0,1,2,3}&lt;/code&gt;. Second mode (size 3, stride 4) = tile offsets &lt;code&gt;{0,4,8}&lt;/code&gt;. Three tiles of four elements each, covering all 12.&lt;/p&gt;
&lt;p&gt;Lets also work through a 2D example &lt;code&gt;B = (4, 6):(1, 4)&lt;/code&gt; (4x6 column-major matrix), tiler &lt;code&gt;A = (2, 3)&lt;/code&gt; (2x3 tiles).
Mode-by-mode:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Mode 0: shape 4, tiler 2 → &lt;code&gt;(2, 2):(1, 2)&lt;/code&gt; — 2 rows in-tile, 2 tile-rows&lt;/li&gt;
&lt;li&gt;Mode 1: shape 6, tiler 3 → &lt;code&gt;(3, 2):(4, 12)&lt;/code&gt; — 3 cols in-tile, 2 tile-cols
Logical divide result: &lt;code&gt;((2, 2), (3, 2)) : ((1, 2), (4, 12))&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is rank-4. Each original mode became a nested pair of &lt;code&gt;(\text{intra}, \text{inter})&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;This layout is difficult to work with due to its nesting, we can reduce it to easier layouts&lt;/p&gt;
&lt;h3&gt;Zipped, Tiled and Flat Divides&lt;/h3&gt;
&lt;p&gt;These are just convenience representations of the logical divide operation. Lets say a layout &lt;code&gt;L&lt;/code&gt; has shape &lt;code&gt;(m, n, l, ....)&lt;/code&gt; and a tiler &lt;code&gt;T&lt;/code&gt; with shape &lt;code&gt;(t_m, t_n)&lt;/code&gt;. The convenience forms are as below&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Logical: &lt;code&gt;((t_m, r_m), (t_n, r_n), ...)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Zipped: &lt;code&gt;((t_m, t_n), (r_m, r_n, l, ...))&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Tiled: &lt;code&gt;((t_m, t_n), r_m, r_n, l, ...) &lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Flat: &lt;code&gt;(t_m, t_n, r_m, r_n, l, ...) &lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Tiled Divide is especially interesting, it regroups the modes by role: all intra-tile modes together, all inter-tile modes together. The result is a clean two-level hierarchy — first group is &quot;what&apos;s inside a tile,&quot; second group is &quot;which tile.&quot; This is the workhorse form that local_tile and partition_* use under the hood. It&apos;s what you want when different parts of the kernel need to reason about tiles independently — the CTA picks its tile via the inter-tile mode, then threads work within it via the intra-tile mode.&lt;/p&gt;
&lt;h2&gt;Product&lt;/h2&gt;
&lt;p&gt;Where division breaks a layout into tiles, product does the opposite — it &lt;em&gt;replicates&lt;/em&gt; a layout to fill a larger space. Given a layout &lt;code&gt;A&lt;/code&gt; (the atom to replicate) and a layout &lt;code&gt;B&lt;/code&gt; (the replication pattern), the product is&lt;/p&gt;
&lt;p&gt;&lt;code&gt;A \otimes B := (A, A^* \circ B)&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;The result is a two-mode layout: mode 0 is the atom &lt;code&gt;A&lt;/code&gt; itself and mode 1 is &lt;code&gt;A^* \circ B&lt;/code&gt; which describes how the copies of &lt;code&gt;A&lt;/code&gt; are arranged. The complement &lt;code&gt;A^*&lt;/code&gt; finds the gaps between elements of &lt;code&gt;A&lt;/code&gt;, and composing with &lt;code&gt;B&lt;/code&gt; maps the replication pattern into those gaps.&lt;/p&gt;
&lt;p&gt;Lets work through a 1D example. Take &lt;code&gt;A = 4:1&lt;/code&gt; (a contiguous atom of 4 elements) and &lt;code&gt;B = 3:1&lt;/code&gt; (replicate 3 times).&lt;/p&gt;
&lt;p&gt;First we need the complement of &lt;code&gt;A&lt;/code&gt; with respect to &lt;code&gt;M = \text{size}(A) \times \text{size}(B) = 12&lt;/code&gt;. We get &lt;code&gt;A^*_{12} = 3:4&lt;/code&gt; — the three offsets &lt;code&gt;\{0, 4, 8\}&lt;/code&gt; where copies begin.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;A^* \circ B = (3:4) \circ (3:1) = 3:4&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;So the product is &lt;code&gt;A \otimes B = (4, 3) : (1, 4)&lt;/code&gt;. Notice anything? This is the same layout we got from dividing &lt;code&gt;12:1&lt;/code&gt; by &lt;code&gt;4:1&lt;/code&gt;. That&apos;s the duality — division splits a layout into tiles, product builds one up from tiles. Two sides of the same coin.&lt;/p&gt;
&lt;p&gt;Where product really shines is building &lt;em&gt;thread-value&lt;/em&gt; layouts for distributing work across GPU threads. Say we have 4 threads each handling 2 values. The thread layout &lt;code&gt;T = 4:1&lt;/code&gt; and value layout &lt;code&gt;V = 2:1&lt;/code&gt;, the product &lt;code&gt;T \otimes V&lt;/code&gt; tells us which elements each thread owns.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;T^*_8 = 2:4&lt;/code&gt;, so &lt;code&gt;T^* \circ V = (2:4) \circ (2:1) = 2:4&lt;/code&gt;. The product is &lt;code&gt;(4, 2) : (1, 4)&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Thread 0 handles elements &lt;code&gt;\{0, 4\}&lt;/code&gt;, thread 1 handles &lt;code&gt;\{1, 5\}&lt;/code&gt; and so on — each thread&apos;s values are spaced apart by the number of threads. This cyclic distribution is exactly what CuTe&apos;s &lt;code&gt;make_tiled_copy_tv&lt;/code&gt; builds internally from a thread layout and value layout.&lt;/p&gt;
&lt;h3&gt;Blocked and Raked Products&lt;/h3&gt;
&lt;p&gt;For multi-dimensional layouts, the logical product works mode-by-mode just like division. Given atom &lt;code&gt;A&lt;/code&gt; with shape &lt;code&gt;(a_m, a_n)&lt;/code&gt; and replication pattern &lt;code&gt;B&lt;/code&gt; with shape &lt;code&gt;(b_m, b_n)&lt;/code&gt;, the result has structure &lt;code&gt;((a_m, r_m), (a_n, r_n))&lt;/code&gt; where &lt;code&gt;r&lt;/code&gt; denotes the replica modes. Blocked and raked products are two ways of reassociating these modes.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Blocked product&lt;/strong&gt; groups like-modes with the atom inside: &lt;code&gt;((a_m, r_m), (a_n, r_n))&lt;/code&gt;. Each atom occupies a contiguous block and replicas tile these blocks across the space. Think of it as &quot;each thread gets a contiguous rectangle of elements.&quot;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Raked product&lt;/strong&gt; reverses the nesting: &lt;code&gt;((r_m, a_m), (r_n, a_n))&lt;/code&gt;. The replicas interleave within the atom dimensions, creating a cyclic distribution. Thread 0 gets element 0, thread 1 gets element 1, wrapping around. This is the classic GPU pattern for coalesced memory access — adjacent threads access adjacent memory locations.&lt;/p&gt;
&lt;h3&gt;Zipped and Tiled Products&lt;/h3&gt;
&lt;p&gt;Same convenience regrouping as with division. Given a layout &lt;code&gt;L&lt;/code&gt; with shape &lt;code&gt;(m, n, l, ...)&lt;/code&gt; and tiler &lt;code&gt;T&lt;/code&gt; with shape &lt;code&gt;(t_m, t_n)&lt;/code&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Logical: &lt;code&gt;((m, t_m), (n, t_n), l, ...)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Zipped: &lt;code&gt;((m, n), (t_m, t_n, l, ...))&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Tiled: &lt;code&gt;((m, n), t_m, t_n, l, ...)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Flat: &lt;code&gt;(m, n, t_m, t_n, l, ...)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Putting it all together&lt;/h2&gt;
&lt;p&gt;Now lets see how all these layout operations come together in a real tiled GEMM kernel using CuTe&apos;s Python DSL. We compute &lt;code&gt;C = A \times B&lt;/code&gt; where &lt;code&gt;A&lt;/code&gt; is &lt;code&gt;M \times K&lt;/code&gt;, &lt;code&gt;B&lt;/code&gt; is &lt;code&gt;K \times N&lt;/code&gt; (stored as &lt;code&gt;N \times K&lt;/code&gt;, i.e. row-major transposed), and &lt;code&gt;C&lt;/code&gt; is &lt;code&gt;M \times N&lt;/code&gt;. The kernel tiles across all three dimensions with block tile sizes &lt;code&gt;(b_M, b_N, b_K)&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;Setup and Tiled Copies&lt;/h3&gt;
&lt;p&gt;First we define shared memory layouts and tiled copy operations for moving data from global memory (GMEM) to shared memory (SMEM):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Shared memory layouts - column-major for vectorized MMA access
sA_layout = cute.make_layout((b_m, b_k))  # (b_m, b_k) : (1, b_m)
sB_layout = cute.make_layout((b_n, b_k))  # (b_n, b_k) : (1, b_n)

# Thread layout for copy A: threads distributed across k-major order
tA = cute.make_layout(
    (num_threads // b_k, b_k), stride=(b_k, 1)
)
copy_atom_A = cute.make_copy_atom(
    cute.nvgpu.cpasync.CopyG2SOp(), dtype,
    num_bits_per_copy=dtype.width,
)
tiled_copy_A = cute.make_tiled_copy_tv(
    copy_atom_A, thr_layout=tA, val_layout=cute.make_layout((1, 1))
)

# Thread layout for copy B: with vectorized loads along n-major
num_vectorized = 4  # elements per vectorized load
copy_atom_B = cute.make_copy_atom(
    cute.nvgpu.cpasync.CopyG2SOp(), dtype,
    num_bits_per_copy=dtype.width * num_vectorized,
)
major_mode_size = b_n // num_vectorized
tB = cute.make_layout(
    (major_mode_size, num_threads // major_mode_size),
    stride=(1, major_mode_size),
)
tiled_copy_B = cute.make_tiled_copy_tv(
    copy_atom_B, thr_layout=tB,
    val_layout=cute.make_layout((num_vectorized, 1))
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;make_tiled_copy_tv&lt;/code&gt; function takes a copy atom (the hardware copy instruction), a thread layout (how threads are mapped to tile elements), and a value layout (how many elements each thread copies per invocation). This is where layout composition shines — CuTe composes these layouts to determine exactly which elements each thread is responsible for copying.&lt;/p&gt;
&lt;h3&gt;MMA Setup&lt;/h3&gt;
&lt;p&gt;The tiled MMA is set up similarly. We distribute threads in a &lt;code&gt;(T/16, 16, 1)&lt;/code&gt; layout across the M, N, K modes:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;atoms_layout_mnk = cute.make_layout(
    (num_threads // 16, 16, 1), stride=(16, 1, 0)
)
tiled_mma = cute.make_tiled_mma(
    cute.nvgpu.MmaUniversalOp(abacc_dtype=acc_dtype),
    atom_layout_mnk=atoms_layout_mnk,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;The Kernel&lt;/h3&gt;
&lt;p&gt;Inside the kernel, we first use &lt;code&gt;local_tile&lt;/code&gt; to carve out each thread block&apos;s portion of the global tensors. The &lt;code&gt;proj&lt;/code&gt; argument selects which modes the block tiler applies to:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;bidx, bidy, _ = cute.arch.block_idx()
cta_coords = (bidx, bidy, None)

# gA: (b_m, b_k, k_tiles), gB: (b_n, b_k, k_tiles), gC: (b_m, b_n)
gA = cute.local_tile(mA, block_tiler, cta_coords, proj=(1, None, 1))
gB = cute.local_tile(mB, block_tiler, cta_coords, proj=(None, 1, 1))
gC = cute.local_tile(mC, block_tiler, cta_coords, proj=(1, 1, None))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then we partition the tiles across threads for both copying and computation:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;tidx, _, _ = cute.arch.thread_idx()
thr_mma = tiled_mma.get_slice(tidx)

# Partition copy source (GMEM) and destination (SMEM) for each thread
thr_copy_A = tiled_copy_A.get_slice(tidx)
tAgA = thr_copy_A.partition_S(gA)  # (cpy, cpy_m, cpy_k, k_tiles)
tAsA = thr_copy_A.partition_D(sA)  # (cpy, cpy_m, cpy_k)

thr_copy_B = tiled_copy_B.get_slice(tidx)
tBgB = thr_copy_B.partition_S(gB)  # (cpy, cpy_n, cpy_k, k_tiles)
tBsB = thr_copy_B.partition_D(sB)  # (cpy, cpy_n, cpy_k)

# Partition SMEM and output for MMA
tCsA = thr_mma.partition_A(sA)
tCsB = thr_mma.partition_B(sB)
tCgC = thr_mma.partition_C(gC)
tCrC = tiled_mma.make_fragment_C(tCgC)
tCrC.fill(0.0)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each &lt;code&gt;partition_*&lt;/code&gt; call internally uses layout composition and division to split the tile into per-thread pieces. The &lt;code&gt;cpy&lt;/code&gt; mode in the copy partitions captures both the vectorization width and the number of copy operations each thread performs.&lt;/p&gt;
&lt;h3&gt;The Main Loop&lt;/h3&gt;
&lt;p&gt;The k-tile loop copies each &lt;code&gt;(b_M, b_K)&lt;/code&gt; and &lt;code&gt;(b_N, b_K)&lt;/code&gt; tile from GMEM to SMEM using async copies, then performs the MMA:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;k_tiles = cute.size(tAgA, mode=[3])

for k in range(k_tiles):
    cute.copy(tiled_copy_A, tAgA[None, None, None, k], tAsA, pred=tApA)
    cute.copy(tiled_copy_B, tBgB[None, None, None, k], tBsB, pred=tBpB)

    cute.arch.cp_async_commit_group()
    cute.arch.cp_async_wait_group(0)
    cute.arch.sync_threads()

    cute.gemm(tiled_mma, tCrC, tCsA, tCsB, tCrC)
    cute.arch.sync_threads()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;pred&lt;/code&gt; arguments handle boundary conditions — when M, N, or K aren&apos;t exact multiples of the tile sizes, predicate tensors mask out-of-bounds accesses. These predicates are themselves built using layout operations on identity tensors.&lt;/p&gt;
&lt;h3&gt;Epilogue&lt;/h3&gt;
&lt;p&gt;Finally the accumulated results are written back to global memory, again with predication for bounds checking:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cC = cute.make_identity_tensor(gC.shape)
tCpC = thr_mma.partition_C(cC)
predC = cute.make_rmem_tensor(tCrC.layout, cutlass.Boolean)
residue_m = mC.shape[0] - b_m * bidx
residue_n = mC.shape[1] - b_n * bidy
for i in range(cute.size(tCrC.shape)):
    predC[i] = cute.elem_less(tCpC[i], (residue_m, residue_n))
atom = cute.make_copy_atom(cute.nvgpu.CopyUniversalOp(), mC.element_type)
cute.copy(atom, tCrC, tCgC, pred=predC)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The full kernel is available in the &lt;a href=&quot;https://github.com/amandeepsp/cuda/blob/master/matmul/fmma_tiled_matmul.py&quot;&gt;companion repo&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Notice how the layout algebra permeates the entire kernel — from defining how threads map to data (&lt;code&gt;make_tiled_copy_tv&lt;/code&gt;), to carving tiles (&lt;code&gt;local_tile&lt;/code&gt;), to splitting work across threads (&lt;code&gt;partition_*&lt;/code&gt;), to bounds checking (&lt;code&gt;make_identity_tensor&lt;/code&gt; + &lt;code&gt;elem_less&lt;/code&gt;). Without these abstractions, we&apos;d be manually computing thread-to-element mappings with error-prone index arithmetic. The layout algebra replaces all of that with composable, type-safe operations.&lt;/p&gt;
&lt;h2&gt;Closing thoughts&lt;/h2&gt;
&lt;p&gt;CuTe&apos;s layout algebra turns what would be pages of error-prone index arithmetic into a handful of composable operations — composition, complement, and division — that are easier to reason about. But I want to be honest about the tradeoff here.&lt;/p&gt;
&lt;p&gt;GPU memory layouts are inherently complex. Swizzled shared memory, bank conflict avoidance, mixed-radix thread-to-data mappings — this complexity is &lt;em&gt;intrinsic&lt;/em&gt; to the hardware. CuTe doesn&apos;t eliminate it; it repackages it into a different formalism. You&apos;re trading one kind of complexity (raw index math) for another (an algebra with its own rules, edge cases, and debugging challenges). As one Reddit commenter &lt;a href=&quot;https://www.reddit.com/r/CUDA/comments/1ihb0wz/thoughts_on_cutlass/&quot;&gt;put it&lt;/a&gt;, CUTLASS is &lt;em&gt;&quot;a typical example of a library with so much abstractions that makes complicated things simple and simple things complicated.&quot;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The learning curve is steep. You need to internalize layouts, composition, complement, division, cosize, cotarget, tiled copies, MMA atoms — a whole vocabulary before you can write your first kernel. For someone who already understands raw CUDA well, the value proposition is debatable: you&apos;re investing significant effort to learn abstractions that, at the end of the day, generate the same PTX. The payoff comes when you need to support multiple GPU architectures, swap out MMA instructions, or restructure tiling strategies without rewriting everything — that&apos;s where the composability genuinely shines. But for a one-off kernel on a single architecture, you might be better off with raw CUDA and a good understanding of your hardware.&lt;/p&gt;
&lt;p&gt;If you want to dig deeper, Cris Cecka&apos;s &lt;a href=&quot;https://arxiv.org/abs/2603.02298&quot;&gt;&quot;CuTe Layout Representation and Algebra&quot;&lt;/a&gt; is the definitive reference — it formalizes everything covered here and more, including inversion and cotarget. The &lt;a href=&quot;https://docs.nvidia.com/cutlass/latest/media/docs/cpp/cute/02_layout_algebra.html&quot;&gt;CuTe documentation&lt;/a&gt; is thorough, and the &lt;a href=&quot;https://docs.nvidia.com/cutlass/latest/media/docs/pythonDSL/overview.html&quot;&gt;CUTLASS Python DSL&lt;/a&gt; makes it easier to experiment with these ideas interactively — the Python interface significantly reduces the compile-time pain that plagues the C++ templates.&lt;/p&gt;
&lt;section&gt;&lt;h2&gt;Footnotes&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;(s_0, s_1, ...): (d_0, d_1, ...)&lt;/code&gt; and listing them elementwise &lt;code&gt;(s_0: d_0), (s_1: d_1), ... &lt;/code&gt; is the same. &lt;a href=&quot;#user-content-fnref-1&quot;&gt;↩&lt;/a&gt; &lt;a href=&quot;#user-content-fnref-1-2&quot;&gt;↩&lt;sup&gt;2&lt;/sup&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/section&gt;</content:encoded></item><item><title>Hierarchical Navigable Small Worlds</title><link>https://amandeepsp.github.io/blog/hnsw/</link><guid isPermaLink="true">https://amandeepsp.github.io/blog/hnsw/</guid><pubDate>Sun, 08 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;import Figure from &quot;@/components/Figure.astro&quot;;&lt;/p&gt;
&lt;p&gt;You might have heard of this algorithm during the RAG-mania of late. But not
many sources on the web go into the inner workings and intuition building,
which I would like to cover here.&lt;/p&gt;
&lt;p&gt;Hierarchical Navigable Small Worlds (HNSW) is a top-performing and popular
approximate nearest neighbor search algorithm. To find the exact nearest
neighbor given a bunch of vectors, we need to find a vector that is the closest
to a query vector. i.e. given a set of vectors &lt;code&gt;X = \{x_i\}&lt;/code&gt;, and a query &lt;code&gt;q&lt;/code&gt;,
distance function &lt;code&gt;\text{dist}(\cdot, \cdot)&lt;/code&gt; the goal is&lt;/p&gt;
&lt;p&gt;&lt;code&gt;x^* = \operatorname*{argmin}_{x_i \in X} \text{dist}(x_i, q)&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;The trivial but brute force way to do this is just iterate through all the
vectors, find a minimum by distance. This is not scalable since the amount of
vectors can be in billions. Another aspect of this is the number of vector
dimensions which can be as large as 4096 in latest state of the art &lt;a href=&quot;https://huggingface.co/blog/mteb&quot;&gt;embedding
models&lt;/a&gt;. We would also want to eliminate any
extra distance calculations, since the time spent in these will not be small.&lt;/p&gt;
&lt;h2&gt;Delaunay Triangulation&lt;/h2&gt;
&lt;p&gt;A common approach when wanting to optimize for multiple queries, is to look for
a data structure we can build on top of current data so we can spend some
compute and memory to lower the per query cost exponentially. What if we could
store a &quot;region&quot; around every vector, such that all points in that region are
the closest to the vector. A query point &lt;code&gt;q&lt;/code&gt; if it falls into &lt;code&gt;p&lt;/code&gt;&apos;s region, &lt;code&gt;p&lt;/code&gt;
by definition is the nearest neighbor of &lt;code&gt;q&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;These regions are called &lt;strong&gt;Voronoi Cells&lt;/strong&gt; and if we draw the region
boundaries, this becomes a &lt;strong&gt;Voronoi Diagram&lt;/strong&gt;. Here is a small visualization.&lt;/p&gt;
&lt;p&gt;We can also represent each point as a node in a graph, and each adjacent
cell as neighbors to the node. This is a mathematically equivalent
representation of the Voronoi Diagram and is called &lt;strong&gt;Delaunay triangulation&lt;/strong&gt;.
This way for each point in the graph, finding the nearest neighbor is trivial,
we just need to get the nearest out of the neighbors of the current node.&lt;/p&gt;
&lt;p&gt;But our problem statement is for point &lt;code&gt;q&lt;/code&gt; outside the given set of points, how
do we use the Delaunay triangulation to answer this question? Imagine we are at
a point &lt;code&gt;p&lt;/code&gt;, such that the point &lt;code&gt;q&lt;/code&gt; is &lt;em&gt;not&lt;/em&gt; in the Voronoi cell of &lt;code&gt;p&lt;/code&gt;. What
is a good decision I can make given the local information I have about my
neighbors? I can find a neighbor &lt;code&gt;r&lt;/code&gt; such that &lt;code&gt;dist(r, q) &amp;lt; dist(p, q)&lt;/code&gt;. But
will this work? Let&apos;s say we move to &lt;code&gt;r&lt;/code&gt; and repeat this process. We can see
that we strictly decrease the &lt;code&gt;dist(\cdot, q)&lt;/code&gt; until we reach a point where we can
find no such &lt;code&gt;r&apos;&lt;/code&gt; such that &lt;code&gt;dist(r&apos;, q) &amp;lt; dist(p&apos;, q)&lt;/code&gt;, which means &lt;code&gt;p&apos;&lt;/code&gt; &lt;em&gt;is&lt;/em&gt;
the nearest neighbor of &lt;code&gt;q&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;You might have noticed that we selected any closer neighbor and not the
&lt;em&gt;closest&lt;/em&gt; neighbor in the &lt;em&gt;Delaunay Walk&lt;/em&gt;, since the process converges to
produce the correct answer anyways, picking the &lt;em&gt;closest&lt;/em&gt; adds extra
computation for a reduction in number of steps the algorithm takes to converge.&lt;/p&gt;
&lt;p&gt;The big problem with this is the &lt;em&gt;curse of dimensionality&lt;/em&gt;, the amount of space
needed to store the graph grows superpolynomially&lt;sup&gt;&lt;a href=&quot;#user-content-fn-1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt; and becomes infeasible to
store beyond small dimensions. This can be easily skirted if we consider an
approximate problem, we can cap the number of neighbors to &lt;code&gt;M&lt;/code&gt; in which case,
the memory requirements reduce to &lt;code&gt;\mathcal{O}(nM)&lt;/code&gt;. But we lose the monotonic
routing behavior we discussed earlier.&lt;/p&gt;
&lt;h2&gt;Small Worlds and their Navigation&lt;/h2&gt;
&lt;p&gt;You might have heard about the &lt;em&gt;six degrees of separation&lt;/em&gt; experiment in which
social psychologist Stanley Milgram&lt;sup&gt;&lt;a href=&quot;#user-content-fn-2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt; asked people to send a letter to a
&lt;em&gt;target&lt;/em&gt; individual, but a person can only forward the letter to a single
acquaintance that they know on a first name basis. About a third of the letters
reached the target person with a median of 6 steps. These and similar
experiments serve as the basic evidence of existence of short paths in the
global friendship network, linking all (or almost all) of us together in
society.&lt;/p&gt;
&lt;p&gt;This kind of small world phenomena is also present in a lot of places outside
society, e.g. the internet itself, power grids, the fully mapped biological
neural network of the worm C. elegans and many more. All this happens because
of &quot;long-range links&quot; between local clusters, e.g. most of your friends are
local; the one overseas friend can shorten the total number of links needed to
reach the destination.&lt;/p&gt;
&lt;p&gt;The same concept can work if we add these long range links to the &lt;em&gt;Delaunay
graph&lt;/em&gt;. We can randomly add new links but adding too many of them, we lose
local structure and the greedy walk will not easily converge since we can&apos;t
determine if we are moving closer or not, we need to settle on some middle
ground strategy. The optimal result comes from Jon Kleinberg&lt;sup&gt;&lt;a href=&quot;#user-content-fn-3&quot;&gt;3&lt;/a&gt;&lt;/sup&gt;. Kleinberg
analyzed a lattice in d dimensions, where long range links are added with the
probability of link from &lt;code&gt;u \rightarrow v&lt;/code&gt;, based on the distance &lt;code&gt;d(u,v)&lt;/code&gt;
between them.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;P(u \rightarrow v) \propto \cfrac{1}{d(u,v)^{\alpha}}&lt;/code&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;If &lt;code&gt;\alpha&lt;/code&gt; is low; the distance doesn&apos;t matter much. Long links are just as likely as short ones. Jumps can overshoot the target.&lt;/li&gt;
&lt;li&gt;If &lt;code&gt;\alpha&lt;/code&gt; is high; the penalty of distance is huge. We mostly just link to nearby links. Jumps can be too timid and might not help.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Kleinberg proved that the optimal result occurs at &lt;code&gt;\alpha = d&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;Intuition Behind &lt;code&gt;\alpha = d&lt;/code&gt; and Runtime Cost&lt;/h3&gt;
&lt;p&gt;The number of points at distance &lt;code&gt;r&lt;/code&gt; in a &lt;code&gt;d&lt;/code&gt;-dimensional space grows &lt;code&gt;\propto
r^d&lt;/code&gt;. And the probability of linking to one such node is &lt;code&gt;\propto r^{-\alpha}&lt;/code&gt;.
This means the total probability of reaching distance &lt;code&gt;r&lt;/code&gt; is &lt;code&gt;\approx
r^{(d-\alpha)}&lt;/code&gt;. Now at &lt;code&gt;\alpha = d&lt;/code&gt;, this probability becomes constant, i.e.
we can reach any distance with the same probability and creates a &lt;em&gt;Scale
Invariance&lt;/em&gt; in the graph.&lt;/p&gt;
&lt;p&gt;Due to the Scale Invariance property of the long-range links, we can imagine a
&quot;ring&quot; &lt;code&gt;R_k&lt;/code&gt; between distance &lt;code&gt;2^{k-1}&lt;/code&gt; to &lt;code&gt;2^k&lt;/code&gt;. There would be
&lt;code&gt;\mathcal{O}(\log n)&lt;/code&gt; such rings. The probability of finding a link to a
certain link in a ring is constant. Let&apos;s try to calculate this. We know &lt;code&gt;P(u
\rightarrow v) \propto d^{-\alpha}&lt;/code&gt;, but we need to normalize the probability.
The normalization denominator would be.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Z = \displaystyle\sum_{u \ne v} \cfrac{1}{d(u, v)^{\alpha}}&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;substituting the ring distances we get&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Z \approx \displaystyle\sum_{k=0}^{\mathcal{O}(\log n)}{2^{k(d-\alpha)}}&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;at &lt;code&gt;d = \alpha&lt;/code&gt;, this becomes a sum of &lt;code&gt;1&lt;/code&gt;s.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Z \sim \mathcal{O}(\log n)&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;this implies the probability of reaching a ring is, ring&apos;s weight (&lt;code&gt;1&lt;/code&gt;) divided by &lt;code&gt;Z&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;P(R_k) \approx \cfrac{1}{Z} \sim \cfrac{1}{\mathcal{O}(\log n)}&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Our goal to move to the Ring &lt;code&gt;R_k&lt;/code&gt; from a previous ring &lt;code&gt;R_{k-1}&lt;/code&gt;, we have only
two choices either we find a long-link and move or continue to explore in the
current ring. The expected number of trials before we succeed is
&lt;code&gt;\mathbb{E}{[T]} = 1/p&lt;/code&gt;, &lt;sup&gt;&lt;a href=&quot;#user-content-fn-4&quot;&gt;4&lt;/a&gt;&lt;/sup&gt; this implies expected number of steps to exit the
ring is &lt;code&gt;\mathcal{O}(\log n)&lt;/code&gt;. Hence.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;\text{Total Cost} = \text{\# of Rings} \times \text{\# of Steps per Ring} = \mathcal{O}(\log^2 n)&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;This is a good improvement, but the algorithm still is very sensitive to input
data and has a tendency to get stuck in a local minimum when data is highly
clustered. This means expensive restarts or adding more links, both of which
increase runtime, we have to tradeoff latency to get a better recall. HNSW
tries to fix robustness while providing an &lt;code&gt;\mathcal{O}(\log n)&lt;/code&gt; query time.&lt;/p&gt;
&lt;h2&gt;Hierarchical Navigable Small Worlds&lt;sup&gt;&lt;a href=&quot;#user-content-fn-5&quot;&gt;5&lt;/a&gt;&lt;/sup&gt;&lt;/h2&gt;
&lt;p&gt;The NSW greedy walk&apos;s run can be seen as a set of &lt;em&gt;zoom in&lt;/em&gt; and &lt;em&gt;zoom out&lt;/em&gt;
phases. Zoom in phase goes to nodes that are highly clustered, on the other
hand the zoom out phases travels long distances. Zoom in phases can get stuck
in local minimas, forcing us to do costly restarts.&lt;/p&gt;
&lt;p&gt;What if we separate out links of different length; start from the longest ones
and progressively switch to shorter ones as we exhaust possibilities in the
longer ones. This could add some robustness since we are not constantly zooming
in and out. Another benefit of this approach is that the number of links will
be a lot less in initial steps and we can explore a larger part of the graph,
making our search cheaper.&lt;/p&gt;
&lt;p&gt;This is the key idea behind the Hierarchy of HNSW, we create layers of graphs
each with increasing coarseness, akin to having different zoom levels.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Layer 0&lt;/strong&gt;: &lt;em&gt;All&lt;/em&gt; the nodes, edges are short and local.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Layer 1&lt;/strong&gt;: Fewer nodes, medium length edges.&lt;/li&gt;
&lt;li&gt;...&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Layer L&lt;/strong&gt;: Top level; very few nodes, we can globally traverse the graph in handful of hops.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is structurally very similar to Skip Lists.&lt;sup&gt;&lt;a href=&quot;#user-content-fn-6&quot;&gt;6&lt;/a&gt;&lt;/sup&gt; &lt;sup&gt;&lt;a href=&quot;#user-content-fn-7&quot;&gt;7&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;p&gt;Querying this structure is easy; we can start on the top layer; just do the
same greedy walk on each layer and move to next layer when we exhaust our
options.&lt;/p&gt;
&lt;h3&gt;Construction&lt;/h3&gt;
&lt;p&gt;Let&apos;s talk construction, so when adding a node to layer &lt;code&gt;l&lt;/code&gt;, we need to (a) add
it to all the layers below &lt;code&gt;l&lt;/code&gt; down to &lt;code&gt;0&lt;/code&gt;. How do we find a layer for a new
node? Also, what would be the best distribution of nodes across layers for fast
queries. We can take a similar geometric distribution from previous section,
since we will get exponentially decreasing nodes in each layer and about
&lt;code&gt;\mathcal{O}(\log n)&lt;/code&gt; layers. So &lt;code&gt;P(L = l) \propto m^{-l}&lt;/code&gt;, &lt;sup&gt;&lt;a href=&quot;#user-content-fn-9&quot;&gt;8&lt;/a&gt;&lt;/sup&gt; here
&lt;code&gt;m&lt;/code&gt; the tunable parameter. Note that this is constant per layer and not
dependent on the number of nodes. This means&lt;/p&gt;
&lt;p&gt;&lt;code&gt;P(L = l) = C \cdot {m}^{-l}&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;To find &lt;code&gt;C&lt;/code&gt;, we sum all the probabilities.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;1 = \displaystyle\sum_{l=0}^{\infty} C{m}^{-l} = C \displaystyle\sum_{l=0}^{\infty} {m}^{-l}&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Using sum of the geometric series, for &lt;code&gt;m &amp;gt; 1&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;1 = C \cdot \cfrac{1}{1 - \frac{1}{m}} \implies C = 1 - \cfrac{1}{m}&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;So&lt;/p&gt;
&lt;p&gt;&lt;code&gt;P(L = l) = \bigg(1 - \cfrac{1}{m}\bigg) \bigg(\cfrac{1}{{m}^l}\bigg)&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;We can also compute the tail probability &lt;code&gt;P(L \geq l)&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;P(L \geq l) = \displaystyle\sum_{k=l}^{\infty} P(L=k) = \displaystyle\sum_{k=l}^{\infty}\bigg(1 - \cfrac{1}{m}\bigg){\bigg(\cfrac{1}{m}\bigg)}^k&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Factoring out &lt;code&gt;(1/{m})^l&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;P(L \geq l) = \bigg(\cfrac{1}{m}\bigg)^l\displaystyle\sum_{t=0}^{\infty}\bigg(1 - \cfrac{1}{m}\bigg){\bigg(\cfrac{1}{m}\bigg)}^t = {m}^{-l}&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;When sampling the level &lt;code&gt;L&lt;/code&gt;, from uniform random distribution we see.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;L = \bigg\lfloor \cfrac{-\ln U}{\ln m} \bigg\rfloor, U \sim \text{Uniform}(0,1)&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;It is also interesting to observe the the expected number of nodes in a layer will be&lt;/p&gt;
&lt;p&gt;&lt;code&gt;\mathbb{E}[N_l] = N \cdot P(L \geq l) = N m^{-l}&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;and the ratio of the layer sizes is&lt;/p&gt;
&lt;p&gt;&lt;code&gt;\cfrac{\mathbb{E}[N_l]}{\mathbb{E}[N_{l+1}]} = m&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Therefore, &lt;code&gt;m&lt;/code&gt; can also be thought of as a zoom factor.&lt;/p&gt;
&lt;h3&gt;Neighbor Selection Heuristic&lt;/h3&gt;
&lt;p&gt;While adding a node we also have to select neighbors to add, we can use the
naive strategy of just picking the &lt;code&gt;M&lt;/code&gt; nodes closest to us, but this strategy
has the same issues with robustness as previously discussed. But we can easily
add link diversity using a simple heuristic. We add a neighbor iff the neighbor
is closer to us than any other neighbors, otherwise we skip. This strategy
creates room in the &lt;code&gt;M&lt;/code&gt; node cap for longer links to fill. To implement this we
can Sort candidates by distance to the new node; keep a candidate if it is not
too close to any already selected neighbor &lt;code&gt;dist(candidate, selected) &amp;lt; dist(candidate, new)&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;Intuition Behind Runtime Cost&lt;/h3&gt;
&lt;p&gt;What is the runtime cost then? We know that we have &lt;code&gt;\mathcal{O}(\log n)&lt;/code&gt;
layers; we just need to find the amount of work needed per layer. Imagine we
have just come down from layer &lt;code&gt;l+1&lt;/code&gt; to &lt;code&gt;l&lt;/code&gt;. Search on this layer would always
terminate before encountering a node that is also present in &lt;code&gt;l+1&lt;/code&gt;, otherwise
the new point in &lt;code&gt;l+1&lt;/code&gt; would have been the entrypoint to layer &lt;code&gt;l&lt;/code&gt;. The
probability of a point being present in both &lt;code&gt;l+1&lt;/code&gt; and &lt;code&gt;l&lt;/code&gt; is&lt;/p&gt;
&lt;p&gt;&lt;code&gt; P(L \geq l+1 | L \geq l) = \cfrac{P(L \geq l+1)}{P(L \geq l)} = \cfrac{{m}^{-(l+1)}}{{m}^{-l}} = 1/{m}&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Note that this is independent of layers and number of nodes. While on a walk of
layer &lt;code&gt;l&lt;/code&gt; we say &lt;code&gt;p&lt;/code&gt; is probability of finding the point in &lt;code&gt;l+1&lt;/code&gt; (from above).
The probability of continuous &lt;code&gt;s&lt;/code&gt; successes is &lt;code&gt;p(1-p)^s&lt;/code&gt;. The expected number
of steps using similar logic as NSW follows as &lt;code&gt;\mathbb{E}[s] = 1/p&lt;/code&gt;, since &lt;code&gt;p
= 1/m&lt;/code&gt;, we get &lt;code&gt;\mathbb{E}[s] = m&lt;/code&gt;. This is independent of number of nodes
hence the total search complexity is &lt;code&gt; \sim \mathcal{O}(\log n)&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;Implementation&lt;/h3&gt;
&lt;p&gt;I am implementing this in Zig, all nodes are stored as indices of a global
vector array. Here is the definition of a &lt;code&gt;Node&lt;/code&gt;. You might have noticed that
we have different sizes for neighbors in layer 0. In practice if we add more
neighbors in layer 0 it can improve the recall. We generally set
&lt;code&gt;neighbor_size0 = 2*neighbor_size&lt;/code&gt;. Each node has neighbors in multiple layers.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pub const NodeIdx = u32;

const Node = struct {
    neighbors: []std.ArrayListUnmanaged(NodeIdx),

    pub fn initEmpty(
        alloc: std.mem.Allocator,
        layer: usize,
        neighbor_size: usize,
        neighbor_size0: usize,
    ) !Node {
        const neighbors = try alloc.alloc(std.ArrayListUnmanaged(NodeIdx), layer + 1);
        for (neighbors, 0..layer + 1) |*nbrs, l| {
            nbrs.* = .empty;
            const node_cap = if (l == 0) neighbor_size0 else neighbor_size;
            try nbrs.ensureTotalCapacityPrecise(alloc, node_cap);
        }

        return .{ .neighbors = neighbors };
    }
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now we define the main data structure for HNSW, here &lt;code&gt;Params&lt;/code&gt; as tunable parameters for the algorithm.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;max_neighbors_per_layer&lt;/code&gt;: (&lt;code&gt;M&lt;/code&gt;) Max. number of neighbors of a node. Large values can increase recall but at the cost of runtime.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ef_construction&lt;/code&gt;: Construction entry factor, number of nearest candidates kept during construction.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ef_search&lt;/code&gt;: Search entry factor, number of nearest candidates kept during search.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;layer_mult&lt;/code&gt;: (&lt;code&gt;m&lt;/code&gt;) Layer Multiplier, usually set to approx. &lt;code&gt;max_neighbors_per_layer&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;pub const HnswIndex = struct {
    pub const Params = struct {
        max_neighbors_per_layer: usize,
        ef_construction: usize,
        ef_search: usize,
        num_words: usize,
        layer_mult: f64,
        seed: u64 = 2026,
    };

    // ...omitted boilerplate vars...

    entry_points: std.ArrayListUnmanaged(NodeIdx),
    layers: usize = 0,
    nodes: std.ArrayListUnmanaged(Node),
    visited: std.DynamicBitSetUnmanaged,
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Let&apos;s add the function to search a single layer. We maintain two heaps: one to
store the node &lt;code&gt;candidates&lt;/code&gt; we want to consider, and &lt;code&gt;farthest&lt;/code&gt; which keeps the
current best &lt;code&gt;ef&lt;/code&gt; results (its top is the worst among them). The rest is
standard best first search.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;fn searchLayer(
    self: *HnswIndex,
    layer: usize,
    idx: NodeIdx,
    entry_points: std.ArrayListUnmanaged(NodeIdx),
    entry_factor: usize,
) !std.ArrayListUnmanaged(NodeIdx) {
    self.visited.unsetAll();

    var candidates = std.PriorityQueue(SearchEntry, void,minCompareSearch).init(self.allocator,{});
    defer candidates.deinit();
    var farthest = std.PriorityQueue(SearchEntry, void, maxCompareSearch).init(self.allocator, {});
    defer farthest.deinit();

    for (entry_points.items) |entry_point| {
        self.visited.set(entry_point);
        try candidates.add(.{ .idx = entry_point, .dist = self.dist(idx, entry_point) });
        try farthest.add(.{ .idx = entry_point, .dist = self.dist(idx, entry_point) });
    }

    while (candidates.count() &amp;gt; 0) {
        const curr_candidate = candidates.remove();
        const curr_idx = curr_candidate.idx;
        const curr_distq = curr_candidate.dist;
        const curr_farthest = farthest.peek();

        if (curr_distq &amp;gt; curr_farthest.?.dist) {
            break;
        }

        for (self.nodes.items[curr_idx].neighbors[layer].items) |nbr| {
            if (!self.visited.isSet(nbr)) {
                self.visited.set(nbr);
                const nbr_dist = self.dist(nbr, idx);
                const curr_farthest_ = farthest.peek();
                if (farthest.count() &amp;lt; entry_factor or nbr_dist &amp;lt; curr_farthest_.?.dist) {
                    try candidates.add(.{ .idx = nbr, .dist = nbr_dist });
                    try farthest.add(.{ .idx = nbr, .dist = nbr_dist });
                    if (farthest.count() &amp;gt; entry_factor) {
                        _ = farthest.remove();
                    }
                }
            }
        }
    }

    const count = farthest.count();
    var results: std.ArrayListUnmanaged(NodeIdx) = .empty;
    try results.resize(self.allocator, count);
    var i: usize = count;
    while (farthest.count() &amp;gt; 0) {
        i -= 1;
        const entry = farthest.remove();
        results.items[i] = entry.idx;
    }

    return results;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For insertion, we assign a random layer using the sampling method discussed
earlier. Then search for entry_points into the &lt;code&gt;assigned_layer&lt;/code&gt;, with
&lt;code&gt;entry_factor = 1&lt;/code&gt;. Then add the new node from the &lt;code&gt;assigned_layer&lt;/code&gt; down to
&lt;code&gt;0&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pub fn insert(self: *HnswIndex, idx: NodeIdx) !void {
    const random = self.prng.random();

    const urandom = random.float(f64);
    const assigned_layer: usize = @intFromFloat(std.math.floor(
        -std.math.log(f64, std.math.e, urandom) / std.math.log(f64, std.math.e, self.layer_mult),
    ));

    const node = try Node.initEmpty(
        self.allocator,
        assigned_layer,
        self.params.max_neighbors_per_layer,
        self.max_neighbors_layer0,
    );

    try self.nodes.append(self.allocator, node);

    if (self.nodes.items.len == 1) {
        try self.entry_points.append(self.allocator, idx);
        self.layers = assigned_layer;
        return;
    }

    if (assigned_layer == self.layers) {
        try self.entry_points.append(self.allocator, idx);
    }

    var curr_entry_points: std.ArrayListUnmanaged(NodeIdx) = self.entry_points;
    var current_layer = self.layers;

    var current_nearest: std.ArrayListUnmanaged(NodeIdx) = undefined;

    while (current_layer &amp;gt; assigned_layer) {
        const new_entry_points = try self.searchLayer(current_layer, idx, curr_entry_points, 1);
        curr_entry_points = new_entry_points;
        current_layer -= 1;
    }

    current_layer = @min(assigned_layer, self.layers) + 1;
    while (current_layer &amp;gt; 0) {
        current_layer -= 1;

        current_nearest = try self.searchLayer(
            current_layer,
            idx,
            curr_entry_points,
            self.params.ef_construction,
        );

        try self.selectNeighbors(current_layer, idx, &amp;amp;current_nearest);

        for (current_nearest.items) |nbr_idx| {
            try self.nodes.items[idx].neighbors[current_layer].append(self.allocator, nbr_idx);

            const nbr_neighbors = &amp;amp;self.nodes.items[nbr_idx].neighbors[current_layer];
            try nbr_neighbors.append(self.allocator, idx);

            const max_neighbors = if (current_layer == 0) {
                self.max_neighbors_layer0
            } else {
                self.params.max_neighbors_layer0
            };
            if (nbr_neighbors.items.len &amp;gt; max_neighbors) {
                try self.selectNeighbors(current_layer, nbr_idx, nbr_neighbors);
            }
        }

        curr_entry_points = current_nearest;
    }

    if (assigned_layer &amp;gt; self.layers) {
        self.layers = assigned_layer;
        self.entry_points.clearRetainingCapacity();
        try self.entry_points.append(self.allocator, idx);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now we add the &lt;code&gt;selectNeighbors&lt;/code&gt; function, this is the neighbor selection
heuristic discussed earlier. We can sort the neighbors and skip nodes that are
closer to another neighbor than to the query &lt;code&gt;idx&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;fn selectNeighbors(
    self: *HnswIndex,
    layer: usize,
    idx: NodeIdx,
    candidates: *std.ArrayListUnmanaged(NodeIdx),
) !void {
    const max_neighbors = if (layer == 0) {
        self.max_neighbors_layer0
    } else {
        self.params.max_neighbors_per_layer
    };

    var candidate_entries: std.ArrayListUnmanaged(SearchEntry) = .empty;
    defer candidate_entries.deinit(self.allocator);

    for (candidates.items) |c| {
        try candidate_entries.append(self.allocator, .{ .idx = c, .dist = self.dist(c, idx) });
    }

    candidates.clearRetainingCapacity();

    std.mem.sort(SearchEntry, candidate_entries.items, {}, minCompare);

    for (candidate_entries.items) |c| {
        var good = true;
        for (candidates.items) |s| {
            if (self.dist(c.idx, s) &amp;lt; c.dist) {
                good = false;
                break;
            }
        }
        if (good) {
            try candidates.append(self.allocator, c.idx);
        }
        if (candidates.items.len &amp;gt;= max_neighbors) {
            break;
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Lastly we can add &lt;code&gt;topK&lt;/code&gt; to find the K nearest neighbors. Here we just greedily
search nodes in all layers except layer 0. On layer 0, we cast a larger net and
filter out the top &lt;code&gt;k&lt;/code&gt; nodes.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pub fn topK(self: *HnswIndex, idx: NodeIdx, k: usize) !std.ArrayListUnmanaged(NodeIdx) {
    var candidates: std.ArrayListUnmanaged(NodeIdx) = undefined;
    var curr_entry_points = self.entry_points;
    var curr_layer = self.layers;

    while (curr_layer &amp;gt; 0) {
        const new_entry_points = try self.searchLayer(curr_layer, idx, curr_entry_points, 1);
        curr_entry_points = new_entry_points;
        curr_layer -= 1;
    }

    candidates = try self.searchLayer(0, idx, curr_entry_points, self.params.ef_search);
    candidates.shrinkRetainingCapacity(k);
    return candidates;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This implementation is not going to be very performant, since I am not using a
lot of optimal data structures and we have a lot of pointer chasing. On my AMD
Ryzen 9 9950x3D (single core) using the &lt;a href=&quot;https://nlp.stanford.edu/projects/glove/&quot;&gt;GloVe
Dataset&lt;/a&gt;&apos;s 50-dim sample (1.29M
vectors), I am getting these results.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Index build time&lt;/strong&gt;: 93.10s&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Average recall&lt;/strong&gt;: 75.00%&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Average query time&lt;/strong&gt;: 92.0µs (100 queries, k=10)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This seems good enough for a quick and dirty implementation. Code can be found
on &lt;a href=&quot;https://github.com/amandeepsp/hnsw&quot;&gt;Github&lt;/a&gt;. Perhaps I will create another
post about optimizing this in the future.&lt;/p&gt;
&lt;section&gt;&lt;h2&gt;Footnotes&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;Why? See &lt;a href=&quot;https://en.wikipedia.org/wiki/Upper_bound_theorem&quot;&gt;McMullen&apos;s Upper Bound Theorem&lt;/a&gt; &lt;a href=&quot;#user-content-fnref-1&quot;&gt;↩&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Travers, Jeffrey, and Stanley Milgram. &quot;An experimental study of the small world problem.&quot; &lt;em&gt;Sociometry&lt;/em&gt; (1969): 425-443. &lt;a href=&quot;#user-content-fnref-2&quot;&gt;↩&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Kleinberg, Jon. &quot;The small-world phenomenon: An algorithmic perspective.&quot; &lt;em&gt;Proceedings of the thirty-second annual ACM symposium on Theory of computing&lt;/em&gt; (2000). &lt;a href=&quot;#user-content-fnref-3&quot;&gt;↩&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;This follows from the geometric distribution. If each trial succeeds with probability &lt;code&gt;p&lt;/code&gt;, then &lt;code&gt;\mathbb{E}[T] = \sum_{k=1}^{\infty} k \cdot p(1-p)^{k-1} = \frac{1}{p}&lt;/code&gt;. Intuitively: with &lt;code&gt;p = 0.5&lt;/code&gt;, you expect 2 coin flips to get heads; with &lt;code&gt;p = 0.1&lt;/code&gt;, you expect 10 trials. &lt;a href=&quot;#user-content-fnref-4&quot;&gt;↩&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Malkov, Yu A., and D. A. Yashunin. &quot;Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs.&quot; &lt;em&gt;IEEE transactions on pattern analysis and machine intelligence&lt;/em&gt; 42.4 (2018): 824-836. &lt;a href=&quot;https://arxiv.org/pdf/1603.09320&quot;&gt;arXiv:1603.09320&lt;/a&gt; &lt;a href=&quot;#user-content-fnref-5&quot;&gt;↩&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Skip_list&quot;&gt;Skip List: Wikipedia&lt;/a&gt; &lt;a href=&quot;#user-content-fnref-6&quot;&gt;↩&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href=&quot;https://eugene-eeo.github.io/blog/skip-lists.html&quot;&gt;An Anlaysis of Skip Lists&lt;/a&gt; &lt;a href=&quot;#user-content-fnref-7&quot;&gt;↩&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;This is not the same in the original paper. Original paper uses &lt;code&gt;exp(-l/{m_L})&lt;/code&gt;. But in my opinion &lt;code&gt;m^{-l}&lt;/code&gt; framing leads to better intuition. &lt;a href=&quot;#user-content-fnref-9&quot;&gt;↩&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/section&gt;</content:encoded></item><item><title>The Rabbit Hole of Building a Filesystem Watcher</title><link>https://amandeepsp.github.io/blog/fs-watcher/</link><guid isPermaLink="true">https://amandeepsp.github.io/blog/fs-watcher/</guid><pubDate>Tue, 23 Sep 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Some of the systems I work with are highly customized environments, and often need support engineers to maintain them.
A lot of automation exists, but sometimes they need to manually go into a VM and change things. This is normal
, but with these manual tasks, mistakes are inevitable. One such case is a service that would only work if all the files
and directories it manages are owned by a special user. But sometimes people run commands in the service directories
as root. This doesn&apos;t impact the service as it&apos;s running, but it won&apos;t restart. While the fix is simple, just &lt;code&gt;chown -R&lt;/code&gt;
the service directory. There are many easy ways to prevent this, e.g. setting file permissions,
&lt;a href=&quot;https://linux.die.net/man/1/setfacl&quot;&gt;File ACLs&lt;/a&gt;. These are less strict as &lt;code&gt;root&lt;/code&gt;
user can override these. Setting SELinux policies would be a much stricter solution. These are very sensible solutions.
But what is the fun it that? How about we build an entire filesystem event watcher ourselves?&lt;/p&gt;
&lt;h2&gt;Attempt 1 - &lt;code&gt;fanotify&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://www.man7.org/linux/man-pages/man7/fanotify.7.html&quot;&gt;&lt;code&gt;fanotify&lt;/code&gt;&lt;/a&gt; is a set of APIs in the Linux kernel by which we
could get filesystem events sent to userspace. Let&apos;s dive in, according to &lt;a href=&quot;https://www.man7.org/linux/man-pages/man7/fanotify.7.html&quot;&gt;man page&lt;/a&gt;,
we first need to call &lt;a href=&quot;https://www.man7.org/linux/man-pages/man2/fanotify_init.2.html&quot;&gt;&lt;code&gt;fanotify_init&lt;/code&gt;&lt;/a&gt; with proper flags;
This sets up a kernel-space notification group. We can set up the directories we need to watch via
&lt;a href=&quot;https://www.man7.org/linux/man-pages/man2/fanotify_mark.2.html&quot;&gt;&lt;code&gt;fanotify_mark&lt;/code&gt;&lt;/a&gt;.
&lt;code&gt;fanotify_init&lt;/code&gt; sets up a file descriptor for the event queue, which can be accessed by reading the file descriptor.
This is a great built-in API, but we have a few issues.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;We cannot monitor a directory recursively. This feature is only available for whole filesystem
mounts.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Another limitation is that &lt;code&gt;fanotify&lt;/code&gt; only gives us the PID of the process that triggered the event (&lt;code&gt;metadata-&amp;gt;pid&lt;/code&gt;),
not the full credentials. If we want to know &lt;em&gt;who&lt;/em&gt; (which UID/GID) actually performed the operation, we must do an extra
lookup in &lt;code&gt;/proc/&amp;lt;pid&amp;gt;&lt;/code&gt; (for example, reading &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/status&lt;/code&gt;) to fetch the task&apos;s credentials. That
means for every single event, we would need to open and parse a &lt;code&gt;/proc&lt;/code&gt; file, and then apply our filtering logic.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Attempt 2 - eBPF&lt;/h2&gt;
&lt;p&gt;This was when I put this idea on the back burner, but then I stumbled on &lt;a href=&quot;https://ebpf.io/what-is-ebpf/&quot;&gt;eBPF&lt;/a&gt;
while working on another project with &lt;a href=&quot;https://falco.org/&quot;&gt;Falco&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;eBPF enables running programs in kernel space. Programs are first compiled into bytecode, then verified by an in-kernel static
verifier, then run using JIT for native execution performance. To communicate with the user-space, we can instantiate various forms of
data structures too. The official intro docs do a great job of explaining this, see &lt;a href=&quot;https://ebpf.io/what-is-ebpf/&quot;&gt;What is eBPF?&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I have been fortunate enough to be writing this at a time when tooling around eBPF has evolved a lot. Earlier tools had to include
kernel headers by either a) compiling the program with the exact kernel source present locally or b) compiling the program on the
server where it will run. Thanks to improvements around adding lightweight type info &lt;a href=&quot;https://nakryiko.com/posts/bpf-portability-and-co-re/&quot;&gt;BTF(BPF Type Format)&lt;/a&gt;
, &lt;a href=&quot;https://nakryiko.com/posts/bpf-portability-and-co-re/&quot;&gt;CO-RE (Compile Once - Run Everywhere)&lt;/a&gt; and &lt;code&gt;libbpf&lt;/code&gt; loader. The user interface
for writing eBPF programs is a bit easier.&lt;/p&gt;
&lt;p&gt;Now the question comes, what do you hook into? We can directly hook into &lt;a href=&quot;https://www.kernel.org/doc/html/latest/filesystems/vfs.html&quot;&gt;kernel VFS layer&lt;/a&gt;
functions such as &lt;code&gt;vfs_mkdir&lt;/code&gt; and &lt;code&gt;vfs_create&lt;/code&gt;, which abstract out various filesystem implementations and expose a single filesystem interface to user-space.
We could read the arguments and filter out the events shipped to userspace, saving on a lot of context switches.&lt;/p&gt;
&lt;p&gt;This method again has its own slew of annoyances.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;Using kprobes on functions like &lt;code&gt;vfs_*&lt;/code&gt; does not guarantee a stable ABI,
i.e the arguments can change anytime, or functions themselves can disappear across kernel releases.
In my case, this is not a big deal since I would be running this in a standardized environment with consistent kernel
versions. But this is a solvable problem, though requiring more engineering effort.
See &lt;a href=&quot;https://nakryiko.com/posts/bpf-core-reference-guide/#dealing-with-kernel-changes-and-feature-detection&quot;&gt;this section about handling kernel change in the BPF-CORE reference&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;We will have to write the path filtering logic in kernelspace using eBPF,
since &lt;code&gt;vfs_*&lt;/code&gt; probes will trigger for &lt;em&gt;all&lt;/em&gt; events. We will have to walk the filesystem tree up
and see if some dir matches our monitored dir. Aside from the complexity of writing this,
each eBPF program is statically verified. It must not contain unbounded loops, and we have a limited stack size (typically 512 bytes).&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;Walking up the file tree in eBPF&lt;/h3&gt;
&lt;p&gt;With the generous help of &lt;a href=&quot;https://nakryiko.com/posts/bpf-core-reference-guide/&quot;&gt;Andrii Nakryiko&apos;s excellent BPF CO-RE reference guide&lt;/a&gt;,
I was able to come up with a good enough solution. We can use the &lt;code&gt;dentry&lt;/code&gt; struct to walk up the tree. But since we can&apos;t
have unbounded loops in BPF, I had to truncate the walk at &lt;code&gt;MAX_DEPTH&lt;/code&gt;,
which is acceptable for my problem statement since the expected depth of the directory I want to monitor is known.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;static bool is_monitored_dir(struct dentry *dentry, __u64 target_ino) {
  bpf_rcu_read_lock();
  struct dentry *curr_dentry = BPF_CORE_READ(dentry, d_parent);
  struct inode *curr_inode;
  __u64 curr_ino;
  bool result = false;

  #pragma unroll
  for(int i=0; i &amp;lt; MAX_DEPTH; i++) {
    if (!curr_dentry) {
      break;
    }

    curr_inode = BPF_CORE_READ(curr_dentry, d_inode);
    curr_ino = BPF_CORE_READ(curr_inode, i_ino);
    if (curr_ino == target_ino) {
      result = true;
      break;
    }

    struct dentry *parent_dentry = BPF_CORE_READ(curr_dentry, d_parent);
    if (curr_dentry == parent_dentry) {
      break; // curr_dentry is its own root, we have reached the top of
                    // the tree.
    }

    curr_dentry = parent_dentry;
  }

  bpf_rcu_read_unlock();
  return result;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note the &lt;a href=&quot;https://www.kernel.org/doc/html/latest/RCU/whatisRCU.html&quot;&gt;kernel RCU (Read, Copy, Update)&lt;/a&gt; locks are needed since the &lt;code&gt;dentry&lt;/code&gt; tree
can change while we are traversing it. The RCU mechanism lets the readers safely traverse without blocking the writers.&lt;/p&gt;
&lt;p&gt;For a complete, working example of this approach, please refer to the fs-watcher &lt;a href=&quot;https://github.com/amandeepsp/fs-watcher&quot;&gt;GitHub repository&lt;/a&gt;
. This repository contains the full source code.&lt;/p&gt;
&lt;h3&gt;Better Probes&lt;/h3&gt;
&lt;p&gt;LSM hooks provide a more stable and semantically meaningful API for monitoring filesystem events, since they are part of the kernel’s
&lt;a href=&quot;https://www.kernel.org/doc/html/latest/security/lsm.html&quot;&gt;Linux Security Module framework&lt;/a&gt;.
They can reduce the number of events you need to filter and eliminate some of the brittleness associated with probing low-level VFS functions.
However, these hooks were not available in the kernel I was working with. With LSM hooks, we have access to the &lt;code&gt;path&lt;/code&gt; struct with which we can resolve
the name into a buffer using &lt;a href=&quot;https://docs.ebpf.io/linux/kfuncs/bpf_path_d_path/&quot;&gt;&lt;code&gt;bpf_path_d_path&lt;/code&gt;&lt;/a&gt;. Then we can do a substring search to see if the
path is monitored or not. I will be sure to try this out after our next infra update.&lt;/p&gt;
&lt;h2&gt;Wrapping Up&lt;/h2&gt;
&lt;p&gt;This little experiment turned out to be a great deep dive into Linux kernel internals, eBPF and various trade-offs of running kernel-space programs.
eBPF is a very powerful tool, but also has very sharp edges if you are not careful. This has also been my most rigorous exercise in RTFM&apos;ing.
A lot of information about these tools exists, but it’s scattered across kernel docs, blog posts, and reference guides. Piecing it all together was
a journey in itself.&lt;/p&gt;</content:encoded></item><item><title>Intuition behind Power of 2 Choices Load balancing</title><link>https://amandeepsp.github.io/blog/power-of-2/</link><guid isPermaLink="true">https://amandeepsp.github.io/blog/power-of-2/</guid><pubDate>Sun, 17 Aug 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;One of the hardest parts of balancing load across many targets is keeping an accurate view of their load.
We have to check all the targets; this is expensive. Also, the process of checking is not instantaneous, which leads to stale data
which in turn causes request herding.
What if we assign a request to a random target? This works surprisingly well but causes hotspots, which is not ideal.
A variant of this is where we select 2 targets at random and then assign the request to the one with less load, which works even better. This
is called the &lt;em&gt;Power of 2 Choices&lt;/em&gt; balancing.&lt;/p&gt;
&lt;p&gt;This has been studied in Maths as the &lt;a href=&quot;http://www.eecs.harvard.edu/~michaelm/postscripts/handbook2001.pdf&quot;&gt;Balls &amp;amp; Bins problem&lt;/a&gt;.
Where we place balls into bins, where a bin can house any number of balls.
From here, we get that the typical max load on a target is exponentially better with 2 choices, i.e. &lt;code&gt; \mathcal{O(\frac{\log n}{\log\log n})}&lt;/code&gt; for random and
&lt;code&gt;\mathcal{O(\frac{\log\log n}{\log d})}&lt;/code&gt; for &lt;code&gt;d&lt;/code&gt; random choices, for 2 this will be &lt;code&gt;\mathcal{O(\log \log n)}&lt;/code&gt;.
From this result, we can also see that going from 1 to 2 improvement is far better than selections of 3 or 4, which are only marginally better.
But it is not obvious intuitively, since we are still selecting the two targets randomly.
This same exponential gap underlies why data structures like &lt;a href=&quot;https://en.m.wikipedia.org/wiki/Cuckoo_hashing&quot;&gt;cuckoo hashing&lt;/a&gt;
work so well: having two random choices dramatically reduces collisions and spreads keys more evenly.&lt;/p&gt;
&lt;p&gt;Here is a visualisation of how the Power of 2 choices approach perform better, as you can see in the gif, the load appears
more uniform as the number of requests increases.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/blog/power-of-2/heatmap.gif&quot; alt=&quot;Simulation for Random vs Power of 2&quot; /&gt;&lt;/p&gt;
&lt;p&gt;If you want to go deeper into the practical side of these tradeoffs, Tyler McCullen’s talk
&lt;a href=&quot;https://www.youtube.com/watch?v=kpvbOzHUakA&quot;&gt;&quot;Load Balancing is Impossible&quot;&lt;/a&gt; and &lt;a href=&quot;https://brooker.co.za/blog/2012/01/17/two-random.html&quot;&gt;Mark Booker&apos;s Blog Post&lt;/a&gt; are excellent resources.&lt;/p&gt;
&lt;p&gt;I recently found a &lt;a href=&quot;https://cs.uwaterloo.ca/~r5olivei/courses/2021-spring-cs466/lecture04.pdf&quot;&gt;UWaterloo slide&lt;/a&gt; about this that had a very intuitive explanation of this.
Say in our servers, &lt;code&gt;N_k&lt;/code&gt; servers are already at max load out of a total &lt;code&gt;n&lt;/code&gt; servers, say max load is &lt;code&gt;k&lt;/code&gt;.
The probability that the max load grows is just &lt;code&gt;N_k/n&lt;/code&gt; since we can only choose one target. But when we get to choose
two targets, what is the probability that the max load grows? What would it take to make &lt;code&gt;k+1&lt;/code&gt;?
We will have to choose two targets that are already at &lt;code&gt;k&lt;/code&gt;, since if only one is at &lt;code&gt;k&lt;/code&gt;, the other target will be
selected, and max will not increase. Now, what is the probability that two targets are at max and we choose both of
them is &lt;code&gt;(N_k/n)^2&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Here, &lt;code&gt;N_k&lt;/code&gt; would already be small. Furthermore to increase the max even more from &lt;code&gt;k+1&lt;/code&gt; to &lt;code&gt;k+2&lt;/code&gt;, the number of servers
with max load would have fallen even more, call it &lt;code&gt;N_{k+1}&lt;/code&gt;, the probability will be &lt;code&gt;(N_{k+1}/n)^2&lt;/code&gt; which is event tinier than
moving from &lt;code&gt;k&lt;/code&gt; to &lt;code&gt;k+1&lt;/code&gt;. This is why the tail of max load in case of 2 choices falls very rapidly, since with
every iteration the probability of increasing the max falls faster than the single choice method.&lt;/p&gt;
&lt;p&gt;Let&apos;s go through with an example&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Say we have &lt;code&gt;n/4&lt;/code&gt; targets with a max of &lt;code&gt;4&lt;/code&gt; requests each, the probability of selecting two of these is &lt;code&gt;1/16&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Now we should expect only &lt;code&gt;n/16&lt;/code&gt; targets to have the max &lt;code&gt;5&lt;/code&gt; requests, and then only &lt;code&gt;n/256 = n/(2^{2^3})&lt;/code&gt; targets with max &lt;code&gt;6&lt;/code&gt; requests&lt;/li&gt;
&lt;li&gt;This amounts to &lt;code&gt;\frac{n}{2^{2^{k-3}}}&lt;/code&gt; for a max of &lt;code&gt;k&lt;/code&gt; requests.&lt;/li&gt;
&lt;li&gt;To find the upper bound of &lt;code&gt;k&lt;/code&gt; at a fixed &lt;code&gt;n&lt;/code&gt; we can set the &lt;code&gt;N_k&lt;/code&gt; to the minimum &lt;code&gt;1&lt;/code&gt;. This will give us &lt;code&gt;k = \mathcal{O(\log \log n)}&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>Exploring Clojure for Advent of Code</title><link>https://amandeepsp.github.io/blog/clojure-aoc21/</link><guid isPermaLink="true">https://amandeepsp.github.io/blog/clojure-aoc21/</guid><pubDate>Wed, 05 Jan 2022 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Very early in my career I had the absolute delight of reading &lt;a href=&quot;https://mitpress.mit.edu/sites/default/files/sicp/full-text/book/book.html&quot;&gt;SICP&lt;/a&gt;
; this was my first introduction Lispy languages. I used &lt;a href=&quot;https://racket-lang.org/&quot;&gt;Racket&lt;/a&gt;
to go through the book at the time, and found it overall enjoyable to use. Although, I found the all the parentheses to be
an acquired taste. Eventually could visually cut through the parentheses.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://clojure.org/&quot;&gt;Clojure&lt;/a&gt; was one other language I wanted to explore in this genre. Aside form being Lispy, some
of its selling points that intrigued me were that a) it is targeted to compiled to JVM bytecode b) had persistent data structures;
that can change with mutating the original memory (by creating new structures efficiently).&lt;/p&gt;
&lt;p&gt;I gave it a go for &lt;a href=&quot;https://adventofcode.com/2021&quot;&gt;Advent of Code 2021&lt;/a&gt;. Here are my solutions, &lt;a href=&quot;https://github.com/amandeepsp/aoc2021/tree/master&quot;&gt;github/aoc2021&lt;/a&gt;.
My overall experience was again also good, here are some of the language features I found very fun to use.&lt;/p&gt;
&lt;h2&gt;Threading Macros&lt;/h2&gt;
&lt;p&gt;These are a very convenient way to compose functions. I like the ergonomics of using them over the manual way of composing
&lt;code&gt;(c (b (a x)))&lt;/code&gt; which is difficult to understand when going through the program. There are two of them in Clojure;
thread-first &lt;code&gt;-&amp;gt;&lt;/code&gt; and thread-last &lt;code&gt;-&amp;gt;&amp;gt;&lt;/code&gt; owing to the fact that the former threads into the first argument of each function
and the latter the last argument.&lt;/p&gt;
&lt;p&gt;Here is an example for file parsing. I also appreciate, when languages have simple IO APIs when sometimes
10-20% of AoC problem code is just input parsing.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;(ns aoc.shared
  (:require [clojure.java.io :as io]
            [clojure.string :as str]))

(defn read-lines [file-resource]
  (-&amp;gt;&amp;gt; (io/resource file-resource)
       (slurp)
       (str/split-lines)))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This was also my most overused feature when solving AoC since there is a huge correlation between composition and breaking
a problem into smaller pieces, for example in Day 14 &lt;a href=&quot;https://adventofcode.com/2021/day/14&quot;&gt;Problem&lt;/a&gt;, we apply a function 11 times
and then calculate the difference of min frequency and max frequency of the characters of the resulting string.
&lt;a href=&quot;https://github.com/amandeepsp/aoc2021/blob/master/src/aoc/day14.clj&quot;&gt;Complete Solution&lt;/a&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;;Part-1
(-&amp;gt;&amp;gt; template
     (iterate apply-subs)
     (take 11)
     (last)
     (frequencies)
     (vals)
     (min-max-diff))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Collections&lt;/h2&gt;
&lt;p&gt;Clojure collections are &lt;em&gt;persistent data structures&lt;/em&gt;, when mutating them we get a new structure, that may or may not
share memory with the original. They provide &lt;code&gt;\approx\mathcal{O}(log_{32}N)&lt;/code&gt; operations. They are based on
Hash Array Mapped &lt;a href=&quot;https://lampwww.epfl.ch/papers/idealhashtrees.pdf&quot;&gt;from Phil Bagwell&apos;s 2001 paper Ideal Hash Trees&lt;/a&gt;.&lt;sup&gt;&lt;a href=&quot;#user-content-fn-1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt;. Here is
an implementation Dijkstra&apos;s shortest path algorithm for &lt;a href=&quot;https://adventofcode.com/2021/day/15&quot;&gt;Day 15&lt;/a&gt;. Note the
&lt;code&gt;assoc&lt;/code&gt; operator, this would be mutating key &lt;code&gt;node&lt;/code&gt; in a map &lt;code&gt;cost&lt;/code&gt; with &lt;code&gt;curr-dist&lt;/code&gt;, but here you get the appearance
of a returning a new map with the changed value. &lt;a href=&quot;https://github.com/amandeepsp/aoc2021/blob/master/src/aoc/day15.clj&quot;&gt;Complete Solution&lt;/a&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;(ns aoc.day15
  (:require [clojure.data.priority-map :refer [priority-map]]))

(defn dijkstra [graph start-coords h w]
  (loop [q (priority-map start-coords 0)
         costs {}]
    (if (empty? q)
      costs
      (let [[node curr-dist] (peek q)
            dist (-&amp;gt;&amp;gt; (valid-neighbors node h w)
                      (filter (complement costs))
                      (map #(vector % (+ (graph %) curr-dist)))
                      (into {}))]
        (recur
         (merge-with min (pop q) dist)
         (assoc costs node curr-dist))))))
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Zippers&lt;/h2&gt;
&lt;p&gt;Input for &lt;a href=&quot;https://adventofcode.com/2021/day/18&quot;&gt;Day 18&lt;/a&gt; are nested vectors, which are tree like. This problem also wants us to edit the tree structure.
Seems like a good place to use the Zipper APIs. A good deep dive for zippers is provided by this
&lt;a href=&quot;https://grishaev.me/en/clojure-zippers/&quot;&gt;Ivan Grishaev post&lt;/a&gt;. But in short: a zipper is a data structure that represents not just a tree,
but also a cursor into that tree; a focused position plus all the context needed to rebuild the whole structure after changes.
In Clojure, (&lt;code&gt;clojure.zip/vector-zip tree&lt;/code&gt;) turns a nested vector into such a navigable structure.
Each zipper location contains/supports:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the current node (&lt;code&gt;zip/node&lt;/code&gt;),&lt;/li&gt;
&lt;li&gt;information about its siblings and parent path, and&lt;/li&gt;
&lt;li&gt;functions to move (&lt;code&gt;zip/down&lt;/code&gt;, &lt;code&gt;zip/up&lt;/code&gt;, &lt;code&gt;zip/left&lt;/code&gt;, &lt;code&gt;zip/right&lt;/code&gt;, &lt;code&gt;zip/next&lt;/code&gt;, &lt;code&gt;zip/prev&lt;/code&gt;) or edit in place (&lt;code&gt;zip/edit&lt;/code&gt;, &lt;code&gt;zip/replace&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Because all of this is purely functional, edits return new zipper locations instead of mutating the original tree.
You can freely descend, modify, and then climb back to the top with &lt;code&gt;zip/root&lt;/code&gt; to recover the updated structure;
exactly what we need for repeated tree rewrites in the problem.&lt;/p&gt;
&lt;h3&gt;Navigating the tree&lt;/h3&gt;
&lt;p&gt;Lets add a few helpers deal with moving through the nested vector and finding specific locations (like leaves or the root).&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;(ns aoc.day18
  (:require [clojure.zip :as z]
            [clojure.walk :as walk]))

(defn leaves-seq [loc step]
  (-&amp;gt;&amp;gt; (iterate step loc)
       rest
       (take-while (complement z/end?))
       (remove z/branch?))) ;filter out branches

(defn next-leaves [number] (leaves-seq number z/next))
(defn prev-leaves [number] (leaves-seq number z/prev))

(defn root-loc [loc]
  (-&amp;gt;&amp;gt; (iterate z/up loc)
       (take-while identity)
       last))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;leaves-seq&lt;/code&gt; walks a zipper in depth-first order, collecting only the leaf nodes (the regular numbers, not pairs).
&lt;code&gt;next-leaves&lt;/code&gt; and &lt;code&gt;prev-leaves&lt;/code&gt; specialize this for scanning forward or backward.
Meanwhile, &lt;code&gt;root-loc&lt;/code&gt; ensures we can always “rewind” to the top of the tree after edits since zipper edits keep you
at the modified node.&lt;/p&gt;
&lt;h3&gt;Exploding deeply nested pairs&lt;/h3&gt;
&lt;p&gt;Exploding is the most complex operation: when a pair is nested inside four pairs, it “explodes”
its left and right values are distributed to the nearest regular numbers on the left and right,
and it’s replaced by &lt;code&gt;0&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;(defn explode [number]
  (if-let [explode-loc
           (-&amp;gt;&amp;gt; number
                next-leaves
                (filter #(&amp;gt; (count (z/path %)) 4))
                first
                z/up)]
    (let [[left-val right-val] (z/node explode-loc)
          explode-loc (z/replace explode-loc 0)
          explode-loc (if-let [left-loc (first (prev-leaves explode-loc))]
                        (-&amp;gt; left-loc (z/edit + left-val) next-leaves first)
                        explode-loc)
          explode-loc (if-let [right-loc (first (next-leaves explode-loc))]
                        (-&amp;gt; right-loc (z/edit + right-val) prev-leaves first)
                        explode-loc)]
      [explode-loc :continue])
    [number :done]))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here we locate the leftmost pair nested deeper than 4 levels &lt;code&gt;((&amp;gt; (count (z/path %)) 4))&lt;/code&gt;.
Once found, we destructure its values, replace the pair with 0, and then use &lt;code&gt;prev-leaves&lt;/code&gt; and &lt;code&gt;next-leaves&lt;/code&gt;
to locate and increment the nearest neighboring numbers. Everything is done immutably,
&lt;code&gt;z/edit&lt;/code&gt; returns a new zipper each time.&lt;/p&gt;
&lt;p&gt;To run explosions repeatedly until stable, we wrap it in full-explode:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;(defn full-explode [number]
  (-&amp;gt;&amp;gt; [number :continue]
       (iterate (fn [[number _]] (explode number)))
       (map-indexed vector)
       (filter (fn [[_ [_ state]]] (= state :done)))
       first
       ((fn [[i [number _]]]
          [number (if (&amp;gt; i 1) :changed :done)]))))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This repeatedly applies explode until it signals &lt;code&gt;:done&lt;/code&gt;, then reports whether anything changed.&lt;/p&gt;
&lt;h3&gt;Reducing to a stable form&lt;/h3&gt;
&lt;p&gt;To fully &lt;em&gt;normalize&lt;/em&gt; a snailfish number, we must apply explosions and splits repeatedly, explosions first,
then splits, until neither changes the tree.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;(defn normalize [number]
  (let [[exploded e-state] (full-explode number)
        [split-n s-state] (split (root-loc exploded))]
    (if (= :done e-state s-state)
      (z/root split-n)
      (recur (root-loc split-n)))))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This recursive loop ensures the number reaches its reduced form.
We always start each pass from the &lt;code&gt;root-loc&lt;/code&gt;, so the next traversal covers the entire tree correctly.
&lt;code&gt;z/root&lt;/code&gt; extracts the final value once normalization is complete.&lt;/p&gt;
&lt;h3&gt;Combining numbers and Magnitude&lt;/h3&gt;
&lt;p&gt;Adding two snailfish numbers simply wraps them in a new vector and normalizes:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;(defn add [n1 n2]
  (normalize (z/vector-zip [n1 n2])))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Finally, we compute the &lt;em&gt;magnitude&lt;/em&gt;; a recursive formula:
&lt;code&gt;magnitude([x, y]) = 3 * magnitude(x) + 2 * magnitude(y)&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;(defn magnitude [number]
  (walk/postwalk
   (fn [node]
     (if (number? node)
       node
       (+ (* 3 (first node)) (* 2 (second node)))))
   number))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;clojure.walk/postwalk&lt;/code&gt; is perfect here, it processes the tree bottom-up, collapsing pairs into scalar magnitudes along the way.&lt;/p&gt;
&lt;p&gt;In Part 1, we sum all snailfish numbers in sequence and compute the final magnitude:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;(magnitude (reduce add input))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each call to &lt;code&gt;add&lt;/code&gt; wraps, normalizes, and returns the reduced result, just like the problem statement.
The zipper operations handle all the nested mutations elegantly, keeping the code declarative and readable.
&lt;a href=&quot;https://github.com/amandeepsp/aoc2021/blob/master/src/aoc/day18.clj&quot;&gt;Complete Solution&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;Chinks in the Armor?&lt;/h2&gt;
&lt;p&gt;So far I have been gushing praises for the language; but there are a lot of sharp edges. Errors have been a big pain
for me and the &lt;a href=&quot;https://ericnormand.me/article/clojure-error-messages-accidental&quot;&gt;Clojure community at large&lt;/a&gt;.
Another one for me is the function discoverability, like how I am supposed to know &lt;a href=&quot;http://clojuredocs.org/clojure.core/remove&quot;&gt;&lt;code&gt;remove&lt;/code&gt;&lt;/a&gt;
exists? &lt;a href=&quot;http://clojuredocs.org/&quot;&gt;Clojure Docs&lt;/a&gt; help but this is not great; since all these are
just clubbed in &lt;code&gt;clojure.core&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Aside from these Clojure has been a joy to work with and I look forward to using it more in my projects. I have not yet
explored a lot of tooling around dependency management, but I have seem people on the web complaining about it.&lt;/p&gt;
&lt;section&gt;&lt;h2&gt;Footnotes&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;Some great resources to understand the inner workings
&lt;a href=&quot;https://hypirion.com/musings/understanding-persistent-vector-pt-1&quot;&gt;Understanding Clojure&apos;s Persistent Vectors, pt. 1&lt;/a&gt;
and later posts from &lt;a href=&quot;https://hypirion.com/category/clojure&quot;&gt;Jean Niklas&lt;/a&gt; &lt;a href=&quot;#user-content-fnref-1&quot;&gt;↩&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/section&gt;</content:encoded></item><item><title>Making Machine Learning models smaller</title><link>https://amandeepsp.github.io/blog/making-ml-models-smaller/</link><guid isPermaLink="true">https://amandeepsp.github.io/blog/making-ml-models-smaller/</guid><pubDate>Tue, 10 Mar 2020 00:00:00 GMT</pubDate><content:encoded>&lt;div&gt;
&lt;p&gt;WARNING&lt;/p&gt;
&lt;p&gt;Contents of this post were published in 2020, while some concepts are still relevant, most of provided code might not work due to framework changes.&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;Machine Learning models are getting bigger and expensive to compute. Embedded devices have restricted memory, computation power and battery. But we can optimize our model to run smoothly on these devices. By reducing the size of the model we decrease the number of operations that need to be done hence reducing the computation. Smaller models also trivially translate into less memory usage. Smaller models are also more power-efficient. One must think that a reduced number of computations is responsible for less power consumption, but on the contrary, the power draw from memory access is about 1000x more costly than addition or multiplication. Now since there are no free lunches i.e. everything comes at a cost, we lose some accuracy of our models here. Bear in mind these speedups are not for training but inference only.&lt;/p&gt;
&lt;h2&gt;Pruning&lt;/h2&gt;
&lt;p&gt;Pruning is removing excess network connections that do not hugely contribute to the output. Ideas of pruning networks are very old dating back to 1990s namely &lt;em&gt;&lt;a href=&quot;https://papers.nips.cc/paper/250-optimal-brain-damage.pdf&quot;&gt;Optimal Brain Damage&lt;/a&gt;&lt;/em&gt; and &lt;em&gt;&lt;a href=&quot;https://papers.nips.cc/paper/749-optimal-brain-surgeon-extensions-and-performance-comparisons.pdf&quot;&gt;Optimal Brain Surgeon&lt;/a&gt;&lt;/em&gt;. These methods use Hessians to determine the importance of connections, which also makes them impractical to use with deep networks. Pruning methods use an iterative training technique i.e. &lt;em&gt;Train ⇒ Prune ⇒ Fine-tune&lt;/em&gt;. Fine-tuning after pruning restores the accuracy of the network lost after pruning. One method is to rank the weights in the network using the L1/L2 norm and remove the last x% of them. Other types of methods which also use ranking use the mean activation of neurons, the number of times a neuron&apos;s activation is zero on a validation set and many other creative methods. This approach is pioneered by &lt;a href=&quot;https://arxiv.org/abs/1506.02626&quot;&gt;Han et.al.&lt;/a&gt; in their 2015 paper.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;assets/pruning.png&quot; alt=&quot;Pruning&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Fig 1. Pruning in neural networks from &lt;a href=&quot;https://arxiv.org/abs/1506.02626&quot;&gt;Han et. al.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Even more recently in 2019, the &lt;a href=&quot;https://arxiv.org/abs/1803.03635&quot;&gt;Frankle et.al.&lt;/a&gt; paper titled &lt;em&gt;The Lottery Ticket Hypothesis&lt;/em&gt; the authors found out that within every deep neural network there exists a subset of it which gives the same accuracy for an equal amount of training. These results hold for unstructured pruning which prunes the whole network which gives us a sparse network. Sparse networks are inefficient on GPUs since there is no structure to their computation. To remedy this, structured pruning is done, which prunes a part of the network e.g. a layer or a channel. The Lottery Ticket discussed earlier is found no to work here by &lt;a href=&quot;https://arxiv.org/abs/1810.05270&quot;&gt;Liu et.al.&lt;/a&gt; They instead discovered that it was better to retrain a network after pruning instead of fine-tuning. Aside from performance is there any other use of sparse networks? Yes, sparse networks are more robust to noise input as shown in a paper by &lt;a href=&quot;https://arxiv.org/abs/1903.11257&quot;&gt;Ahmed et.al.&lt;/a&gt; Pruning is supported in both TF (&lt;code&gt;tensorflow_model_optimization&lt;/code&gt; package) and PyTorch (&lt;code&gt;torch.nn.utils.prune&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;To use pruning in PyTorch you can either select a technique class from &lt;code&gt;torch.nn.utils.prune&lt;/code&gt; or implement your subclass of &lt;code&gt;BasePruningMethod&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from torch.nn.utils import prune
tensor = torch.rand(2, 5)
pruner = prune.L1Unstructured(amount=0.7)
pruned_tensor = pruner.prune(tensor)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To prune a module we can use pruning methods (basically wrappers on the classes discussed above) given in &lt;code&gt;torch.nn.utils.prune&lt;/code&gt; and specify which module you want to prune, or even which parameter within that module.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;conv_1 = nn.Conv(3, 1, 2)
prune.ln_structured(module=conv_1, name=&apos;weight&apos;, amount=5, n=2, dim=1)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This replaces the parameter &lt;code&gt;weight&lt;/code&gt; with the pruned result and adds a parameter &lt;code&gt;weight_orig&lt;/code&gt; that stores the unpruned version of the input. The pruning mask is stored as &lt;code&gt;weight_mask&lt;/code&gt; and saved as a module buffer. These can be checked by the &lt;code&gt;module.named_parameters()&lt;/code&gt; and &lt;code&gt;module.named_buffers()&lt;/code&gt;. To enable iterative pruning we can use just apply the pruning method for the next iteration and it just works, due to &lt;code&gt;PruningContainer&lt;/code&gt; as it handles computation of final mask taking into account previous prunings using the &lt;code&gt;compute_mask&lt;/code&gt; method.&lt;/p&gt;
&lt;h2&gt;Quantization&lt;/h2&gt;
&lt;p&gt;Quantization is to restrict the number of possible values a weight can take, this will reduce the memory a weight can reduce and in turn reduce the model size. One way of doing this is changing the bit-width of the floating-point number used for storing the weights. A number stored as a 32-bit floating-point or FP32 to an FP16 or an 8-bit fixed-point number and more increasingly an 8-bit integer. Bit width reductions have many advantages as below.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Moving from 32-bit to 8-bit gives us a &lt;em&gt;4x&lt;/em&gt; memory advantage straight away.&lt;/li&gt;
&lt;li&gt;Lower bit width also means that we can squeeze me more numbers in registers/caches with leads to less RAM access and in-turn less time and power consumption.&lt;/li&gt;
&lt;li&gt;Integer computation is always faster than floating-point ones.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This works because neural nets are pretty robust to small perturbations to their weights and we can easily round off them without having much effect on the accuracy of the network. Moreover, weights are not contained in very large ranges due to regularization techniques used in training, hence we do not have to use large ranges, say ~ &lt;code&gt;−3.4×10^{38}&lt;/code&gt; to &lt;code&gt;3.4×10^{38}&lt;/code&gt; for a 32-bit floating -point. For example, in the image below the weight values in MobileNet are all very close to zero.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;assets/mobilenet1-weight-distribution.png&quot; alt=&quot;MobileNet1 Weight Distribution&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Fig 2. Weight distribution of 10 layers of MobileNetV1.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;A Quantization scheme is how we transform our real weights to quantized one, a very rudimentary form of the scheme is linear scaling. Say we want to transform values in range &lt;code&gt;[r_{min},r_{max}]&lt;/code&gt; to an integer range of &lt;code&gt;[0, I_{max}]&lt;/code&gt;, where &lt;code&gt;I_{max}&lt;/code&gt; is &lt;code&gt;2^B -1&lt;/code&gt; , 𝐵 being the bit-width of our integer representation. Hence,&lt;/p&gt;
&lt;p&gt;&lt;code&gt;r = \frac {r_{max} - r_{min}} {I_{max} - 0} {(q - z)} = s(q -z) &lt;/code&gt;&lt;/p&gt;
&lt;p&gt;where &lt;em&gt;r&lt;/em&gt; is the original value of the weight, 𝑠 is the scale, 𝑞&lt;em&gt;q&lt;/em&gt; is the quantized value and 𝑧 is the value that maps to &lt;code&gt;0.0f&lt;/code&gt;. This is also known as an &lt;em&gt;affine mapping&lt;/em&gt;. Since 𝑞 is integer results are rounded off. Now the problem arises how we choose &lt;code&gt; r*{min}&lt;/code&gt; and&lt;code&gt; r*{max}&lt;/code&gt;. A simple method to achieve this is generating distributions of weights and activations and then taking their &lt;em&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Kullback%E2%80%93Leibler_divergence&quot;&gt;KL divergences&lt;/a&gt;&lt;/em&gt; with quantized distributions and use the one with min divergence from the original. A more elegant way to do this is using &lt;em&gt;Fake Quantization&lt;/em&gt; i.e. introduce quantization aware layers into the network during training. This idea is proposed by &lt;em&gt;&lt;a href=&quot;https://arxiv.org/abs/1712.05877&quot;&gt;Jacob et. al.&lt;/a&gt;&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;assets/fake_quant.png&quot; alt=&quot;Fake Quantization&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Fig 3. (a) Normal conv layer, (b) Conv layer with fake quantization units added, (c) Comparison of quantized network&apos;s latency and accuracy. Image from&lt;/em&gt; &lt;em&gt;&lt;a href=&quot;https://arxiv.org/abs/1712.05877&quot;&gt;Jacob et.al.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;While training the &lt;em&gt;Fake quantization&lt;/em&gt; node calculates the ranges for the weights and activations and store their moving average. After training, we quantize the network with this range to get better performance.&lt;/p&gt;
&lt;p&gt;More drastic bit-width also explored in papers on XOR nets by &lt;em&gt;&lt;a href=&quot;https://arxiv.org/abs/1603.05279&quot;&gt;Rastegari et.al&lt;/a&gt;&lt;/em&gt;, Ternary nets by &lt;em&gt;&lt;a href=&quot;https://arxiv.org/abs/1602.02830&quot;&gt;Courbariaux et. al.&lt;/a&gt;&lt;/em&gt; and Binary nets by &lt;em&gt;&lt;a href=&quot;https://arxiv.org/abs/1612.01064&quot;&gt;Zhu et. al.&lt;/a&gt;&lt;/em&gt; In PyTorch 1.3, quantization support was introduced. Three new data types are introduced for quantization operations &lt;code&gt;torch.quint8&lt;/code&gt;, &lt;code&gt;torch.qint8&lt;/code&gt; and &lt;code&gt;torch.qint32&lt;/code&gt;. It also offers various qunatization techniques included in &lt;code&gt;torch.quantization&lt;/code&gt; package.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Post Training Dynamic quantization&lt;/strong&gt; : Replaces float weights with dynamic quantized versions of them. Weight-only quantization by default is performed for layers with large weights size - i.e. Linear and RNN variants.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;quantized_model = torch.quantization.quantize_dynamic(
    model, {nn.LSTM, nn.Linear}, dtype=torch.qint8
)
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Post Training Static quantization&lt;/strong&gt; : Static quantization not only converts float weights to int, but it also records the distribution of activations and they are used to determine the scale of quantization at inference time. To support this calibration type quantization we add &lt;code&gt;QuantStub&lt;/code&gt; at the start of the model and &lt;code&gt;DeQuantStub&lt;/code&gt; and the end of the model. It involves steps mentioned below.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;myModel = load_model(saved_model_dir + float_model_file).to(&apos;cpu&apos;)
# Fuse Conv, bn and relu
myModel.fuse_model()

# Specify quantization configuration
# Start with simple min/max range estimation and per-tensor
# quantization of weights
myModel.qconfig = torch.quantization.default_qconfig

torch.quantization.prepare(myModel, inplace=True)

# Calibrate with the training set
evaluate(myModel, criterion, data_loader,
            neval_batches=num_calibration_batches)

# Convert to quantized model
torch.quantization.convert(myModel, inplace=True)
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Quantization Aware Training&lt;/strong&gt; : Uses &lt;em&gt;fake quantization&lt;/em&gt; modules to store scales while training. For enabling QAT, we use the &lt;code&gt;qconfig&lt;/code&gt; to be &lt;code&gt;get_default_qat_qconfig(&apos;fbgemm&apos;)&lt;/code&gt; and instead of &lt;code&gt;prepare&lt;/code&gt; use &lt;code&gt;prepare_qat&lt;/code&gt;. After this, we can train or fine-tune our model and at the end of the training, get out the quantized model using &lt;code&gt;torch.quantization.convert&lt;/code&gt; same as above.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Post-training quantization in PyTorch currently only support operations on CPU.&lt;/p&gt;
&lt;p&gt;For detailed code examples visit the PyTorch documentation &lt;em&gt;&lt;a href=&quot;https://pytorch.org/tutorials/advanced/dynamic_quantization_tutorial.html&quot;&gt;here&lt;/a&gt;&lt;/em&gt;. On Tensorflow side of things quantization can be done using TFLite&apos;s &lt;code&gt;tf.lite.TFLiteConverter&lt;/code&gt; API by setting the &lt;code&gt;optimizations&lt;/code&gt; parameter to &lt;code&gt;tf.lite.Optimize.OPTIMIZE_FOR_SIZE&lt;/code&gt;. Fake quantization is enabled by &lt;code&gt;tf.contrib.quantize&lt;/code&gt; package.&lt;/p&gt;
&lt;h2&gt;Low Rank Transforms&lt;/h2&gt;
&lt;p&gt;Low-rank transform means representing a matrix or tensor as a product of some lower rank components. These components often only approximate the original matrix but benefit hugely in space and computational efficiency. For example, fully connected layers can be compressed using plain old &lt;a href=&quot;https://en.wikipedia.org/wiki/Singular_value_decomposition#Truncated_SVD&quot;&gt;Truncated SVD&lt;/a&gt; as a fully connected layer can be represented as a matrix. In truncated SVD matrix &lt;em&gt;M&lt;/em&gt; of size 𝑛×𝑚 is approximated by &lt;code&gt;\tilde{M} = U\Sigma V^T&lt;/code&gt;, where &lt;code&gt;U&lt;/code&gt; is  &lt;code&gt;n×t&lt;/code&gt;, Σ is a diagonal matrix of size &lt;code&gt;𝑡×𝑡&lt;/code&gt; and &lt;code&gt;V&lt;/code&gt; is &lt;code&gt;t×m&lt;/code&gt; in size. A fully connected layer can be represented as &lt;code&gt;Wx+b&lt;/code&gt;, where &lt;code&gt;W&lt;/code&gt; is the weight matrix and &lt;code&gt;b&lt;/code&gt; are the biases. We now represent the FC layer as&lt;/p&gt;
&lt;p&gt;&lt;code&gt;(U \Sigma V^T x) + b = U (\Sigma V^T x) + b&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;hence we can split our FC layer into two;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The first layer with shape &lt;code&gt;n×t&lt;/code&gt;, having no biases and weights taken from &lt;code&gt;\Sigma V^T&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Second layer with shape &lt;code&gt;t×m&lt;/code&gt;, original biases and weights from &lt;code&gt;U&lt;/code&gt;. This drops the number of weights from &lt;code&gt;n×m&lt;/code&gt; to &lt;code&gt;t(n+m)&lt;/code&gt;. Time complexity is also reduced by the same factor.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This can be easily implemented as in &lt;em&gt;PyTorch&lt;/em&gt; using the &lt;code&gt;torch.svd&lt;/code&gt; method as depicted in the code snippet below. Here &lt;code&gt;vgg16&lt;/code&gt; is a pre-trained model picked from &lt;code&gt;torchvision.models&lt;/code&gt;. I have applied SVD on Linear layers after training. It can also be applied before training, but that involves calculating the gradient of the SVD operation which is a hassle.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;svd_classifier_layers = []
L = 50
for layer in vgg16.classifier:
  if isinstance(layer, nn.Linear):
    W = layer.weight.data
    U, S, V = torch.svd(W)
    W1 = U[:,:L]
    W2 = torch.diag(S[:L]) @ V[:,:L].t()
    layer_1 = nn.Linear(in_features=layer.in_features,
                        out_features=L, bias=False)
    layer_1.weight.data = W2
    svd_classifier_layers.append(layer_1)

    layer_2 = nn.Linear(in_features=L,
                        out_features=layer.out_features, bias=True)
    layer_2.weight.data = W1
    layer_2.bias.data = layer.bias.data
    svd_classifier_layers.append(layer_2)
  else:
    svd_classifier_layers.append(layer)

svd_vgg16.classifier = nn.Sequential(*svd_classifier_layers)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This results in size reduction from &lt;code&gt;528MB&lt;/code&gt; to &lt;code&gt;195M&lt;/code&gt; i.e. &lt;strong&gt;~ 2.7x decrease&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;This works so well works because the&lt;/p&gt;
&lt;p&gt;majority of the weights in a VGG16 are in Fully Connected layers. For much newer network e.g. ResNets majority of the weights lie in the Conv layers, therefore it makes more sense to apply Low rank transforms to Conv layers. Since conv layers are 4D tensors i.e &lt;code&gt;(batch, channels, width, height)&lt;/code&gt;, SVD and its cousins will not work here. We need to apply specialized tensor decomposition techniques such as CP decomposition (&lt;em&gt;&lt;a href=&quot;https://arxiv.org/pdf/1412.6553.pdf&quot;&gt;Lebedev et.al.&lt;/a&gt;&lt;/em&gt; in 2015) and Tucker Decomposition (&lt;em&gt;&lt;a href=&quot;https://arxiv.org/pdf/1511.06530.pdf&quot;&gt;Kim et. al.&lt;/a&gt;&lt;/em&gt; in 2016). Not covering these papers in more detail because these techniques are now superseded by efficient architectures like SqueezeNet and MobileNet which are discussed in the next section.&lt;/p&gt;
&lt;h2&gt;Efficient network architectures&lt;/h2&gt;
&lt;p&gt;Rather than applying size reducing techniques to existing architectures, we try to create novel architectures that decrease the model size and try to preserve the accuracy of the network over the time there have been many such architectures, prominent of them being SqueezeNet, MobileNet V1 and MobileNet V2.&lt;/p&gt;
&lt;h3&gt;SqueezeNet&lt;/h3&gt;
&lt;p&gt;SqueezeNet by &lt;em&gt;&lt;a href=&quot;https://arxiv.org/pdf/1602.07360.pdf&quot;&gt;Iandola et.al.&lt;/a&gt;&lt;/em&gt; is presumably the first to explore a new architecture for smaller CNNs. At the core of SqueezeNet are &lt;strong&gt;Fire Modules&lt;/strong&gt;. Fire modules use &lt;code&gt;1x1&lt;/code&gt; filters rather than &lt;code&gt;3x3&lt;/code&gt; filters as they have 9x lesser parameters and have a lesser number of channels than normal, which is called a &lt;em&gt;squeeze&lt;/em&gt; layer. The lesser number of channels are recovered in the expand layer which consists of several zero-padded &lt;code&gt;1x1&lt;/code&gt; filters and &lt;code&gt;3x3&lt;/code&gt; filters. The number of filters in the squeeze layers and expand layers are hyper-parameters. If &lt;code&gt; 𝑒*{3×3}+𝑒*{1×1}&lt;/code&gt; are the number of filters in expand layer and &lt;code&gt;s*{1×1}&lt;/code&gt; is the number of filters in the squeeze layer. When using Fire module &lt;code&gt;s*{1×1} &amp;lt; e*{3×3}+e*{1×1}&lt;/code&gt; works best.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;assets/fire_module.png&quot; alt=&quot;Fire Module&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Fig 4. Fire module with&lt;/em&gt; &lt;code&gt;s_{1×1}=3, e_{1×1}=4&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; &lt;code&gt;e_{3×3}=4&lt;/code&gt;. &lt;em&gt;(&lt;a href=&quot;https://arxiv.org/pdf/1602.07360.pdf&quot;&gt;Source&lt;/a&gt;)&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Code for the Fire Module adapted from &lt;code&gt;torchvision.models&lt;/code&gt;. Here &lt;code&gt;inchannels&lt;/code&gt; are the number of input channels, &lt;code&gt;squeeze_planes&lt;/code&gt; are the number of output channels, &lt;code&gt;expand1x1_planes&lt;/code&gt; and &lt;code&gt;expand3x3_planes&lt;/code&gt; are the output channel number for the expand layer. They are generally same.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class Fire(nn.Module):

    def __init__(self, inplanes, squeeze_planes,
                 expand1x1_planes, expand3x3_planes):
        super(Fire, self).__init__()
        self.inplanes = inplanes
        self.squeeze = nn.Conv2d(inplanes, squeeze_planes, kernel_size=1)
        self.squeeze_activation = nn.ReLU(inplace=True)
        self.expand1x1 = nn.Conv2d(squeeze_planes, expand1x1_planes,
                                   kernel_size=1)
        self.expand1x1_activation = nn.ReLU(inplace=True)
        self.expand3x3 = nn.Conv2d(squeeze_planes, expand3x3_planes,
                                   kernel_size=3, padding=1)
        self.expand3x3_activation = nn.ReLU(inplace=True)

    def forward(self, x):
        x = self.squeeze_activation(self.squeeze(x))
        return torch.cat([
            self.expand1x1_activation(self.expand1x1(x)),
            self.expand3x3_activation(self.expand3x3(x))
        ], 1)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;SqueezeNet also uses delayed-sampling to create larger activation maps towards the &lt;em&gt;end&lt;/em&gt; layers, which in turn leads to greater accuracy. The full architecture can be visualized &lt;em&gt;&lt;a href=&quot;https://dgschwend.github.io/netscope/#/preset/squeezenet&quot;&gt;here&lt;/a&gt;&lt;/em&gt;.&lt;/p&gt;
&lt;h3&gt;MobileNets&lt;/h3&gt;
&lt;p&gt;MobileNets are specifically developed by Google to specifically run on mobile devices. MobileNets were first introduced in a paper by &lt;em&gt;&lt;a href=&quot;https://arxiv.org/pdf/1704.04861.pdf&quot;&gt;Howard et.al.&lt;/a&gt;&lt;/em&gt; in 2017, subsequently, in 2018 an improved version was introduced called MobileNet v2 in &lt;em&gt;&lt;a href=&quot;https://arxiv.org/pdf/1801.04381.pdf&quot;&gt;Sandler et. al.&lt;/a&gt;&lt;/em&gt;. The gist of optimization in MobileNet v1 lies in a special kind of convolution layer called &lt;strong&gt;Depthwise separable convolutions&lt;/strong&gt;. For a simple convolution layer if 𝑘 is the dimension of the kernel, &lt;code&gt;𝑁𝑘 &lt;/code&gt;is the number of kernels, and the input is of size &lt;code&gt;𝑁_c×𝑊×𝐻&lt;/code&gt;, where &lt;code&gt;𝑁_𝑐&lt;/code&gt; are the number of input channels. The total number of parameters and computations are &lt;code&gt;k^2N_kN_cWH&lt;/code&gt;. MobileNet Convolutions work in two stages&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Convolve a &lt;code&gt;k×k&lt;/code&gt; for each channel of the input and stack &lt;code&gt;N_c&lt;/code&gt; of them, creating an output tensor of size &lt;code&gt;N_c×W×H&lt;/code&gt;. Total number of ops in this layer is &lt;code&gt;k^2N_cWH&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Convolve with a 1×1 filter with &lt;code&gt;N_k&lt;/code&gt; channels to create the final output. Total number of computations in this stage is &lt;code&gt;N_cN_kWH&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Total computations in a MobileNet convolution are &lt;code&gt;k^2N_cWH+N_cN_kWH&lt;/code&gt;. There total reduction in parameters in given by,&lt;/p&gt;
&lt;p&gt;&lt;code&gt;\frac {k^2 N_c W H + N_c N_k WH} {k^2 N_k N_c WH} = \frac {1} {N_k} + \frac {1} {k^2}&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;For &lt;code&gt;k=3&lt;/code&gt;, &lt;code&gt; 𝑁_k=16&lt;/code&gt; we have a &lt;strong&gt;~ 5.76x&lt;/strong&gt; reduction in number of parameters for a layer.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;assets/depthwise.png&quot; alt=&quot;Depthwise network&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Fig 5. Depthwise seperable convolution followed by pointwise convolutions (&lt;strong&gt;&lt;a href=&quot;https://eli.thegreenplace.net/2018/depthwise-separable-convolutions-for-machine-learning/&quot;&gt;Source&lt;/a&gt;&lt;/strong&gt;)&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Implementing Depthwise conv. is quite simple. Checkout the code snippet below, &lt;code&gt;inp&lt;/code&gt; donates the number of input channels and &lt;code&gt;oup&lt;/code&gt; are the number of output channels.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def conv_dw(inp, oup, stride):
    return nn.Sequential(
        nn.Conv2d(inp, inp, 3, stride, 1, groups=inp, bias=False),
        nn.BatchNorm2d(inp),
        nn.ReLU(inplace=True),

        nn.Conv2d(inp, oup, 1, 1, 0, bias=False),
        nn.BatchNorm2d(oup),
        nn.ReLU(inplace=True),
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;MobileNet v2&lt;/strong&gt; uses as an inverted residual block as its main convolutional layer. A Residual block taken from the &lt;em&gt;&lt;a href=&quot;https://arxiv.org/abs/1512.03385&quot;&gt;ResNets&lt;/a&gt;&lt;/em&gt; includes bottleneck layers that decrease the number of channels followed by an expansion layer that restores the number of channels for the residual concat operation. The inverted block layer does the reverse of that it first expands the number of channels then reduce them. The last layer in the block is a bottleneck layer as it decreases the channels of the output. This layer has to non-linearity attached to it. This because authors found out that a linear bottleneck does not lose information when a feature-map is embedded into a lower dimension space i.e. reduced to a tensor with less number of channels. This is found to increase the accuracy of these networks. To calculate the number of parameters, presume &lt;code&gt;N_{in}&lt;/code&gt; is the number of input channels, &lt;code&gt;N_{out}&lt;/code&gt; the number of output channels and &lt;code&gt;𝑡&lt;/code&gt; is the expansion ratio, the ratio between the size of the intermediate layer to the input layer. The number of computations and parameters are &lt;code&gt;WHN_{in}t(N_{in}+k^2+N_{out})&lt;/code&gt;. But there is an extra &lt;code&gt;1x1&lt;/code&gt; convolution component, still, we have a computational advantage because due to the nature of the block we can now decrease the input and output dimensions e.g. a layer with dimensions &lt;code&gt;112x112&lt;/code&gt; can have only &lt;code&gt;16&lt;/code&gt; channels and retaining the accuracy as compared to &lt;code&gt;64&lt;/code&gt; for MobileNet v1.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;assets/InvResidualBlock.png&quot; alt=&quot;Inverse residual block&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Fig 6. MobileNet v2 primary convolution block.(&lt;strong&gt;&lt;a href=&quot;https://machinethink.net/blog/mobilenet-v2&quot;&gt;Source&lt;/a&gt;&lt;/strong&gt;)&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The code for the &lt;code&gt;InvertedResidual&lt;/code&gt; block is adapted from &lt;code&gt;trochvision.models&lt;/code&gt; package.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class InvertedResidual(nn.Module):
    def __init__(self, inp, oup, stride, expand_ratio):
        super(InvertedResidual, self).__init__()
        self.stride = stride
        assert stride in [1, 2]

        hidden_dim = int(round(inp * expand_ratio))
        self.use_res_connect = self.stride == 1 and inp == oup

        layers = []
        if expand_ratio != 1:
            # pw
            layers.append(ConvBNReLU(inp, hidden_dim, kernel_size=1))
        layers.extend([
            # dw
            ConvBNReLU(hidden_dim, hidden_dim, stride=stride,
                       groups=hidden_dim),
            # pw-linear
            nn.Conv2d(hidden_dim, oup, 1, 1, 0, bias=False),
            nn.BatchNorm2d(oup),
        ])
        self.conv = nn.Sequential(*layers)

    def forward(self, x):
        if self.use_res_connect:
            return x + self.conv(x)
        else:
            return self.conv(x)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Knowledge Distillation&lt;/h2&gt;
&lt;p&gt;Knowledge Distillation (KD) is a model compression technique by which the behaviour of a smaller (student) model is trained to replicate the behaviour of a larger (teacher) model. The term was first coined by none other than Geoffrey Hinton in his &lt;a href=&quot;https://arxiv.org/pdf/1503.02531.pdf&quot;&gt;2015 paper&lt;/a&gt;. KD involves training a smaller network on the weighted average of soft target output of the larger model and the ground truth. Soft target output can be obtained by calculating the softmax on the logits of the larger model, but this creates large divides between the probabilities of the correct label and the wrong label, thus not creating much information other than the ground truth. To remedy this problem Hinton introduces &lt;em&gt;softmax with temperature&lt;/em&gt; given by&lt;/p&gt;
&lt;p&gt;&lt;code&gt;q_i = \frac {exp(\frac {z_i} T)} {\sum_j exp(\frac {z_j} T)}&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;where &lt;code&gt;T&lt;/code&gt; the temperature parameter, &lt;code&gt;T=1&lt;/code&gt; gives the same result as a simple softmax. As Tgrows the probabilities grow softer, providing more information about the model. The overall loss function of the now student-teacher pair becomes&lt;/p&gt;
&lt;p&gt;&lt;code&gt;\mathcal{L} = \lambda \mathcal{L}_{gt} + (1 - \lambda) \mathcal{L}_{temp}&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;where &lt;code&gt;\mathcal{L}_{gt}&lt;/code&gt; is the loss with ground truth outputs and &lt;code&gt;\mathcal{L}_{temp}&lt;/code&gt; is the softmax temperature loss. Both &lt;code&gt;\lambda &lt;/code&gt;and &lt;code&gt;𝑇&lt;/code&gt;are tunable hyperparameters. The loss configuration is as in the image below.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;assets/kd.png&quot; alt=&quot;KD&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Fig 7. Knowledge distillation model configuration. (&lt;strong&gt;&lt;a href=&quot;https://nervanasystems.github.io/distiller/knowledge_distillation.html&quot;&gt;Source&lt;/a&gt;&lt;/strong&gt;)&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;A major success story of KD is &lt;a href=&quot;https://medium.com/huggingface/distilbert-8cf3380435b5&quot;&gt;DistillBERT&lt;/a&gt;. &lt;a href=&quot;https://huggingface.co/&quot;&gt;Hugging Face&lt;/a&gt; managed to use KD to reduce the size of the BERT from 110M parameters to 66M parameters, while still retaining 97% of the performance of the original model. DistillBERT uses various additional tricks to achieve this such as using KD loss instead of standard cross-entropy to retain the probability distribution of the teacher model. The code to train a KD model will go like below. This code is adapted from DistilBERT training sequence itself.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.optim import Optimizer

celoss = nn.CrossEntropyLoss
lambda_ = 0.5

def kd_step(teacher: nn.Module,
            student: nn.Module,
            temperature: float,
            inputs: torch.tensor,
            optimizer: Optimizer):
    teacher.eval()
    student.train()

    with torch.no_grad():
        logits_t = teacher(inputs=inputs)
    logits_s = student(inputs=inputs)

    loss_gt = celoss(input=F.log_softmax(logits_s/temperature, dim=-1),
                     target=labels)
    loss_temp = celoss(input=F.log_softmax(logits_s/temperature, dim=-1),
                       target=F.softmax(logits_t/temperature, dim=-1))
    loss = lambda_ * loss_gt + (1 - lambda_) * loss_temp

    loss.backward()
    optimizer.step()
    optimizer.zero_grad()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There are many more methods to make ML models smaller which I cannot cover as the posts would become too long. More and more research is being done on this, to follow the research be sure to check to &lt;a href=&quot;https://www.arxiv-sanity.com/&quot;&gt;arixv-sanity&lt;/a&gt;. Will try to introduce a further reading section in future.&lt;/p&gt;</content:encoded></item></channel></rss>