<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[My dev blogs]]></title><description><![CDATA[My dev blogs]]></description><link>https://milanpramod.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>My dev blogs</title><link>https://milanpramod.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sat, 19 Sep 2026 19:34:19 GMT</lastBuildDate><atom:link href="https://milanpramod.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Building a Lossless File Compressor from Scratch in C: My Journey with Shannon-Fano, Bitstreams, and Low-Level Performance]]></title><description><![CDATA[Like most developers, I’ve used compression tools like gzip, zip, and tar for years without ever really understanding how they work under the hood. You pass a file in, run a command, and out comes a f]]></description><link>https://milanpramod.hashnode.dev/building-a-lossless-file-compressor-from-scratch-in-c-my-journey-with-shannon-fano-bitstreams-and-low-level-performance</link><guid isPermaLink="true">https://milanpramod.hashnode.dev/building-a-lossless-file-compressor-from-scratch-in-c-my-journey-with-shannon-fano-bitstreams-and-low-level-performance</guid><dc:creator><![CDATA[Milan Pramod]]></dc:creator><pubDate>Sun, 06 Sep 2026 16:50:37 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a9d9628378010e6b2fc65fb/1119b692-c833-4929-82e4-5f72871b5df2.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Like most developers, I’ve used compression tools like <code>gzip</code>, <code>zip</code>, and <code>tar</code> for years without ever really understanding how they work under the hood. You pass a file in, run a command, and out comes a file half the size. It feels like magic.</p>
<p>Recently, I decided to pull back the curtain and learn low-level systems programming by building a lossless compression tool from scratch in C</p>
<p>The project is called <code>kmprs</code> (available on <a href="https://github.com/shadowmkj/kmprs">GitHub</a>).</p>
<p>In this post, I want to share the full journey: how the Shannon-Fano algorithm works, the practical challenges of reading and writing arbitrary bits to disk, how an innocent-looking function call created a 100-million-operation bottleneck, and why building this from zero taught me more about computer architecture than any textbook.</p>
<hr />
<h2>The Core Idea: What is Entropy Encoding?</h2>
<p>In standard ASCII or raw binary data, every single character occupies a fixed size of <strong>8 bits (1 byte)</strong>.</p>
<p>Whether a byte is the letter <code>'e'</code> (which appears thousands of times in English prose) or the character <code>'~'</code> (which might appear once), they both take up 8 bits of disk space:</p>
<pre><code class="language-plaintext">'e' -&gt; 01100101 (8 bits)
'~' -&gt; 01111110 (8 bits)
</code></pre>
<p><strong>Entropy encoding</strong> flips this premise on its head:</p>
<ul>
<li><p>Assign <strong>short bit codes</strong> (e.g., 2 to 4 bits) to frequently occurring symbols.</p>
</li>
<li><p>Assign <strong>longer bit codes</strong> (e.g., 9 to 14 bits) to rare symbols.</p>
</li>
</ul>
<p>Because common characters appear millions of times, the average number of bits per character drops well below 8, compressing the overall file size.</p>
<hr />
<h2>How the Shannon-Fano Algorithm Works</h2>
<p>In 1948, Claude Shannon and Robert Fano introduced one of the earliest statistical prefix-coding algorithms.</p>
<p>The algorithm builds a binary prefix tree using a <strong>top-down recursive splitting</strong> approach:</p>
<pre><code class="language-plaintext">                         [ All Symbols (Sum = 100) ]
                                  /       \
                       Split at ~50       Split at ~50
                               /             \
                   '0' [ Group A (52) ]   '1' [ Group B (48) ]
                         /        \             /        \
                    '0' [e (30)] '1' [t (22)] '0' [a (28)] '1' [z (20)]
</code></pre>
<h3>The Step-by-Step Algorithm</h3>
<ol>
<li><p><strong>Count Frequencies:</strong> Perform a first pass over the input file to build a histogram of all 256 possible byte values ($0$ to $255$).</p>
</li>
<li><p><strong>Filter &amp; Sort:</strong> Collect all symbols with non-zero counts and sort them in descending order of frequency.</p>
</li>
<li><p><strong>Recursive Partitioning:</strong></p>
<ul>
<li><p>Find a split index $k$ such that the sum of frequencies on the left is as close as possible to the sum on the right: $$\left| \sum_{i=\text{start}}^{k} \text{freq}[i] - \sum_{i=k+1}^{\text{end}} \text{freq}[i] \right| \text{ is minimized}$$</p>
</li>
<li><p>Assign bit <code>0</code> to the left group and bit <code>1</code> to the right group.</p>
</li>
<li><p>Recursively split both halves until every group contains a single symbol.</p>
</li>
</ul>
</li>
<li><p><strong>Generate Prefix Codes:</strong> Each symbol receives a unique binary code corresponding to its path from the root.</p>
</li>
</ol>
<h3>The Prefix-Free Property</h3>
<p>A crucial rule in data compression is that <strong>no code can be a prefix of another code</strong>.</p>
<p>For example, if <code>'e'</code> is encoded as <code>01</code>, no other character can start with <code>01</code> (like <code>011</code>). This guarantees that when the decompressor reads incoming bits sequentially, it can instantaneously and unambiguously decode each character without needing delimiter markers.</p>
<hr />
<h2>The Reality Check: Building Bit-Level I/O</h2>
<p>The math of Shannon-Fano is simple on paper. But as soon as you sit down to implement it in C, you run into your first major hardware hurdle:</p>
<blockquote>
<p><strong>Computers do not read or write individual bits.</strong></p>
</blockquote>
<p>The OS filesystem and CPU architecture work in chunks of bytes (8 bits), words (64 bits), and pages (4 KiB). If symbol <code>'e'</code> has the 3-bit codeword <code>101</code> and symbol <code>'t'</code> has the 5-bit codeword <code>01100</code>, how do you write 8 bits across arbitrary boundaries?</p>
<p>To solve this, I had to build custom <code>BitWriter</code> and <code>BitReader</code> abstractions.</p>
<h3>The BitWriter Architecture</h3>
<p>The <code>BitWriter</code> uses a 64-bit integer as a <strong>bit accumulator</strong> (reservoir). It packs variable-length bits into the accumulator and siphons off completed 8-bit bytes:</p>
<pre><code class="language-c">typedef struct BitWriter {
    FILE *out;
    uint8_t buffer[4096];     // 4 KiB block buffer
    size_t buffer_pos;         // Cursor in buffer
    uint64_t accumulator;      // 64-bit temporary bit reservoir
    uint8_t bits_in_buffer;    // Unwritten bits count (0 to 64)
} BitWriter;
</code></pre>
<p>When writing a codeword of length $L$:</p>
<ol>
<li><p>Left-shift the accumulator by $L$ bits.</p>
</li>
<li><p>Bitwise-OR the new codeword into the lower $L$ bits.</p>
</li>
<li><p>Increment <code>bits_in_buffer</code> by $L$.</p>
</li>
<li><p>Whenever <code>bits_in_buffer &gt;= 8</code>, extract the top 8 bits, store them in the output buffer, and decrement <code>bits_in_buffer</code> by 8.</p>
</li>
</ol>
<pre><code class="language-c">static inline void bit_writer_write(BitWriter *bw, uint32_t code, uint8_t length) {
    uint64_t mask = (length == 32U) ? 0xFFFFFFFFULL : ((1ULL &lt;&lt; length) - 1ULL);
    bw-&gt;accumulator = (bw-&gt;accumulator &lt;&lt; length) | ((uint64_t)code &amp; mask);
    bw-&gt;bits_in_buffer += length;

    while (bw-&gt;bits_in_buffer &gt;= 8U) {
        bw-&gt;bits_in_buffer -= 8U;
        uint8_t byte = (uint8_t)((bw-&gt;accumulator &gt;&gt; bw-&gt;bits_in_buffer) &amp; 0xFFU);
        bw-&gt;buffer[bw-&gt;buffer_pos++] = byte;
        if (bw-&gt;buffer_pos == 4096) {
            fwrite(bw-&gt;buffer, 1, 4096, bw-&gt;out);
            bw-&gt;buffer_pos = 0;
        }
    }
}
</code></pre>
<hr />
<h2>Designing the Binary Container Format (<code>.shn</code>)</h2>
<p>A raw stream of compressed bits is useless by itself. When decompressing, the program needs to know:</p>
<ol>
<li><p>Is this actually a valid compressed file?</p>
</li>
<li><p>What codebook was used to encode the file?</p>
</li>
<li><p>How many uncompressed bytes should we restore? (Since the final byte in a bitstream often contains trailing zero padding).</p>
</li>
</ol>
<p>I designed a binary container format with a fixed metadata header:</p>
<pre><code class="language-plaintext">+-------------------------------------------------------------+
| Magic Bytes: "\x7fSHN\x01" (4 bytes)                        |
+-------------------------------------------------------------+
| Original File Size: uint64_t (8 bytes, little-endian)       |
+-------------------------------------------------------------+
| Symbol Count: uint16_t (2 bytes)                            |
+-------------------------------------------------------------+
| Serialized Codebook Entries: [Symbol (1B) | Len (1B) | ...] |
+-------------------------------------------------------------+
| Compressed Bitstream Payload ...                            |
+-------------------------------------------------------------+
</code></pre>
<p>During decompression, <code>kmprs</code> parses the header, reconstructs the Shannon-Fano binary decode tree, and reads bits from the bitstream to traverse the tree from root to leaf, emitting exact original characters until the original file byte count is reached.</p>
<hr />
<h2>The 100-Million-Call Bottleneck &amp; Optimization</h2>
<p>Once the compressor worked end-to-end and passed verification roundtrips, I benchmarked it on a <strong>100 MB test dataset</strong> (<code>dummy.data</code>).</p>
<p>The first implementation felt surprisingly sluggish (~1.33 seconds).</p>
<h3>Finding the Bottleneck</h3>
<p>In my initial prototype of <code>BitWriter</code>, whenever 8 bits accumulated, I emitted the byte immediately using <code>fputc()</code>:</p>
<pre><code class="language-c">// Naive unbuffered approach
while (bw-&gt;bits_in_buffer &gt;= 8) {
    bw-&gt;bits_in_buffer -= 8;
    uint8_t byte = (uint8_t)(bw-&gt;accumulator &gt;&gt; bw-&gt;bits_in_buffer);
    fputc(byte, bw-&gt;out);  // &lt;-- PROBLEM!
}
</code></pre>
<p>On a 100 MB file, this meant:</p>
<ul>
<li><p><strong>~100,000,000 function calls</strong> into the C standard library.</p>
</li>
<li><p><strong>100,000,000 thread lock/unlock operations</strong> (since standard libc file streams like <code>fputc</code> acquire internal reentrant locks per call).</p>
</li>
<li><p>Cache thrashing and function prologue/epilogue overhead inside the innermost encoding loop.</p>
</li>
</ul>
<h3>The Two-Tier Solution</h3>
<p>I re-architected the I/O pipeline:</p>
<ol>
<li><p><strong>Inlined Bit Packing:</strong> Moved <code>bit_writer_write()</code> to <code>bit_io.h</code> as a <code>static inline</code> function so the compiler could optimize the bit shifts directly inside the encoding loop.</p>
</li>
<li><p><strong>4 KiB Block Buffer:</strong> Accumulated completed bytes into an internal <code>uint8_t buffer[4096]</code> array.</p>
</li>
<li><p><strong>Bulk</strong> <code>fwrite()</code><strong>:</strong> Only flushed to the OS stream once every 4,096 bytes.</p>
</li>
</ol>
<h3>The Benchmark Results</h3>
<p>Testing with <a href="https://github.com/sharkdp/hyperfine"><code>hyperfine</code></a> on the 100 MB test payload:</p>
<table>
<thead>
<tr>
<th>Version</th>
<th>Mean Execution Time</th>
<th>User CPU Time</th>
<th>System Time</th>
<th>Speedup</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Buffered BitWriter (4 KiB + inline)</strong></td>
<td><strong>710.5 ms ± 18.0 ms</strong></td>
<td><strong>625.7 ms</strong></td>
<td>79.6 ms</td>
<td><strong>~1.87x faster (2.0x CPU reduction)</strong></td>
</tr>
<tr>
<td>Unbuffered BitWriter (per-byte <code>fputc</code>)</td>
<td>1.327 s ± 0.002 s</td>
<td>1.249 s</td>
<td>74.0 ms</td>
<td>Baseline</td>
</tr>
</tbody></table>
<img src="https://raw.githubusercontent.com/shadowmkj/kmprs/main/perff.png" alt="Benchmark" style="display:block;margin:0 auto" />

<p>Cutting execution time in half simply by buffering bytes and avoiding function call overhead in hot loops was a huge practical lesson.</p>
<hr />
<h2>Real-World Comparison: <code>kmprs</code> vs <code>gzip</code></h2>
<p>When benchmarked against standard <code>gzip -kf dummy.data</code>:</p>
<table>
<thead>
<tr>
<th>Command</th>
<th>Mean Execution Time</th>
<th>User Time</th>
<th>Compressed Size</th>
<th>Space Savings</th>
</tr>
</thead>
<tbody><tr>
<td><code>kmprs dummy.data</code></td>
<td><strong>690.1 ms ± 4.6 ms</strong></td>
<td><strong>620.1 ms</strong></td>
<td><strong>53.25 MB</strong></td>
<td><strong>49.2%</strong></td>
</tr>
<tr>
<td><code>gzip -kf dummy.data</code></td>
<td>2.684 s ± 0.007 s</td>
<td>2.645 s</td>
<td>59.72 MB</td>
<td>43.0%</td>
</tr>
</tbody></table>
<img src="https://raw.githubusercontent.com/shadowmkj/kmprs/main/perf-1.png" alt="Benchmark vs Gzip" style="display:block;margin:0 auto" />

<h3>Why is <code>kmprs</code> faster than <code>gzip</code>?</h3>
<p><code>kmprs</code> performs a single frequency pass, builds a small 256-element tree, and directly streams bits through an inlined bit-reservoir. It does very little memory allocation and has minimal computational complexity.</p>
<h3>But why is <code>gzip</code> the better general-purpose compressor?</h3>
<p>This brings us to an important distinction in data compression theory:</p>
<ol>
<li><p><strong>Order-0 Entropy vs. Dictionary Compression:</strong></p>
<ul>
<li><p><code>kmprs</code> only looks at individual byte frequencies (order-0 entropy). It cannot detect repeated phrases, patterns, or words.</p>
</li>
<li><p><code>gzip</code> uses <strong>DEFLATE</strong>, which combines <strong>LZ77</strong> sliding-window dictionary matching with Huffman coding. When compressing source code, JSON, logs, or prose, LZ77 replaces entire repeated strings (like <code>"function"</code> or <code>&lt;div class="..."&gt;</code>) with tiny <code>(distance, length)</code> tokens, achieving vastly superior compression ratios.</p>
</li>
</ul>
</li>
<li><p><strong>Shannon-Fano is Suboptimal Compared to Huffman:</strong></p>
<ul>
<li><p>Shannon-Fano is a top-down greedy heuristic that divides probabilities in half. It does not guarantee the minimum possible expected code length.</p>
</li>
<li><p>David Huffman later proved that a bottom-up priority-queue approach generates the mathematically optimal prefix codebook.</p>
</li>
</ul>
</li>
</ol>
<hr />
<h2>What Writing C in 2026 Taught Me</h2>
<p>Building a low-level tool in C is unforgiving, but modern tooling makes it a fantastic learning experience:</p>
<ul>
<li><p><strong>AddressSanitizer &amp; UBSan (</strong><code>-fsanitize=address,undefined</code><strong>):</strong> Caught subtle bugs immediately, including a 32-bit shift overflow when masking 32-bit codewords (<code>1ULL &lt;&lt; 32</code> vs <code>(length == 32) ? 0xFFFFFFFF : ...</code>).</p>
</li>
<li><p><strong>Clang-Tidy:</strong> Enforced clean typing, explicit conversions, and consistent header hygiene across all compilation units.</p>
</li>
<li><p><strong>Automated Testing:</strong> Writing unit tests for truncated headers, corrupt magic bytes, and single-byte edge cases caught bugs before they hit production.</p>
</li>
</ul>
<hr />
<h2>Conclusion &amp; What's Next</h2>
<p>Taking a compression algorithm from theoretical pseudocode to a working, optimized CLI binary gave me a deep appreciation for systems programming. Concepts like bitwise operations, cache locality, branch predictability, and I/O buffer management went from abstract textbook ideas to tangible, measurable engineering realities.</p>
<h3>Roadmap for <code>kmprs</code>:</h3>
<ul>
<li><p>[ ] <strong>Table-Driven Multi-Bit Peek Decoder:</strong> Accelerate decompression using an 8-bit lookup table ($O(1)$ symbol resolution).</p>
</li>
<li><p>[ ] <strong>Canonical Huffman Coding:</strong> Replace Shannon-Fano with true Huffman coding and pack headers using canonical code lengths.</p>
</li>
<li><p>[ ] <strong>CRC32 Checksum Verification:</strong> Add stream integrity verification.</p>
</li>
</ul>
<p>If you'd like to check out the code, run the benchmarks, or contribute:</p>
<p>⭐ <strong>GitHub Repository:</strong> <a href="https://github.com/shadowmkj/kmprs">github.com/shadowmkj/kmprs</a></p>
<p>Have you ever built a compression tool or worked with bit-level I/O? What were your biggest takeaways? I’d love to hear your thoughts in the comments!</p>
]]></content:encoded></item></channel></rss>