<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Vadym Stupakov</title>
    <description>The latest articles on DEV Community by Vadym Stupakov (@redeyed).</description>
    <link>https://dev.to/redeyed</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F841357%2F9203c46e-f46c-4026-837d-2d8fb8c455b7.jpeg</url>
      <title>DEV Community: Vadym Stupakov</title>
      <link>https://dev.to/redeyed</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/redeyed"/>
    <language>en</language>
    <item>
      <title>The rsync Algorithm Is Brilliant. Why Don't We See It Everywhere?</title>
      <dc:creator>Vadym Stupakov</dc:creator>
      <pubDate>Mon, 19 Oct 2026 18:44:27 +0000</pubDate>
      <link>https://dev.to/redeyed/the-rsync-algorithm-is-brilliant-why-dont-we-see-it-everywhere-3hhc</link>
      <guid>https://dev.to/redeyed/the-rsync-algorithm-is-brilliant-why-dont-we-see-it-everywhere-3hhc</guid>
      <description>&lt;p&gt;Most developers know &lt;code&gt;rsync&lt;/code&gt; as a command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rsync &lt;span class="nt"&gt;-av&lt;/span&gt; &lt;span class="nb"&gt;source&lt;/span&gt;/ server:/destination/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It copies files. It resumes transfers. It avoids transferring unnecessary data.&lt;/p&gt;

&lt;p&gt;But underneath the familiar CLI is a much more interesting idea: &lt;strong&gt;the rsync algorithm can synchronize two similar files without sending either entire file across the network.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It was designed for a world where bandwidth was expensive.&lt;/p&gt;

&lt;p&gt;The surprising part is that, decades later, we don't see the exact algorithm used very often outside rsync.&lt;/p&gt;

&lt;p&gt;Why?&lt;/p&gt;

&lt;p&gt;To answer that, we need to separate &lt;strong&gt;rsync the tool&lt;/strong&gt; from &lt;strong&gt;rsync the algorithm&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Imagine two machines.&lt;/p&gt;

&lt;p&gt;The receiver has:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;old.bin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sender has:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;new.bin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both files are 10 GB.&lt;/p&gt;

&lt;p&gt;Only 20 MB actually changed.&lt;/p&gt;

&lt;p&gt;The obvious solution is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;send new.bin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's 10 GB of network traffic.&lt;/p&gt;

&lt;p&gt;Another possibility is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sender gets old.bin
sender computes diff(old.bin, new.bin)
sender sends patch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But now the receiver must upload its 10 GB file first.&lt;/p&gt;

&lt;p&gt;That defeats the purpose.&lt;/p&gt;

&lt;p&gt;The rsync algorithm solves a more interesting problem:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How can the sender discover which parts of &lt;code&gt;new.bin&lt;/code&gt; already exist inside &lt;code&gt;old.bin&lt;/code&gt; without receiving &lt;code&gt;old.bin&lt;/code&gt;?&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 1: The Receiver Creates Signatures
&lt;/h2&gt;

&lt;p&gt;The receiver divides its file into blocks.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;old.bin

[ block 0 ]
[ block 1 ]
[ block 2 ]
[ block 3 ]
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For every block it calculates two checksums:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;block 0 -&amp;gt; weak checksum + strong checksum
block 1 -&amp;gt; weak checksum + strong checksum
block 2 -&amp;gt; weak checksum + strong checksum
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;strong&gt;weak checksum&lt;/strong&gt; is cheap and, crucially, can be updated incrementally.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;strong checksum&lt;/strong&gt; is much more resistant to collisions and is used to verify a candidate match.&lt;/p&gt;

&lt;p&gt;The receiver sends these signatures to the sender.&lt;/p&gt;

&lt;p&gt;It does &lt;strong&gt;not&lt;/strong&gt; send the file itself.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: The Sender Searches Its File
&lt;/h2&gt;

&lt;p&gt;The sender now has &lt;code&gt;new.bin&lt;/code&gt; and a compact description of the receiver's blocks.&lt;/p&gt;

&lt;p&gt;Suppose the block size is four bytes.&lt;/p&gt;

&lt;p&gt;The sender can examine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ABCD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; BCDE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  CDEF
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and so on.&lt;/p&gt;

&lt;p&gt;Normally, calculating a checksum for every possible window would be expensive.&lt;/p&gt;

&lt;p&gt;But rsync uses a &lt;strong&gt;rolling checksum&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If we already know the checksum of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ABCD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;we can efficiently derive the checksum of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BCDE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;by removing the contribution of &lt;code&gt;A&lt;/code&gt; and adding &lt;code&gt;E&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;checksum(ABCD)
      |
remove A
add E
      |
checksum(BCDE)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The update is O(1).&lt;/p&gt;

&lt;p&gt;This means rsync can efficiently slide through the new file looking for blocks that exist on the receiver.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Weak Match, Strong Verification
&lt;/h2&gt;

&lt;p&gt;A weak checksum is fast, but collisions are possible.&lt;/p&gt;

&lt;p&gt;So rsync does something clever:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rolling checksum
      |
      v
possible match?
      |
     yes
      |
      v
strong checksum
      |
      v
actual match?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cheap operation runs constantly.&lt;/p&gt;

&lt;p&gt;The expensive operation runs only when necessary.&lt;/p&gt;

&lt;p&gt;This pattern is useful far beyond file synchronization:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Use a cheap approximate test to find candidates, then use an expensive reliable test to verify them.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 4: Send Instructions Instead of the File
&lt;/h2&gt;

&lt;p&gt;Suppose the sender discovers that most of its file already exists on the receiver.&lt;/p&gt;

&lt;p&gt;Instead of sending the entire file, it can send something conceptually like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;COPY block 0
COPY block 1

LITERAL &amp;lt;new bytes&amp;gt;

COPY block 3
COPY block 4
COPY block 5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The receiver reconstructs the new file using its existing data plus the new bytes.&lt;/p&gt;

&lt;p&gt;If a 10 GB file changed by only 20 MB, the network transfer can therefore be dramatically smaller than 10 GB.&lt;/p&gt;

&lt;p&gt;That is the magic of rsync.&lt;/p&gt;




&lt;h1&gt;
  
  
  Why Not Just Compare Blocks?
&lt;/h1&gt;

&lt;p&gt;You might wonder why we need the rolling checksum at all.&lt;/p&gt;

&lt;p&gt;Why not simply split both files into 4 MB blocks and compare their hashes?&lt;/p&gt;

&lt;p&gt;Consider inserting one byte at the beginning.&lt;/p&gt;

&lt;p&gt;Old:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[AAAA][BBBB][CCCC][DDDD]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;New:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;X[AAA][ABBB][BCCC][CDDD]...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every fixed block boundary has shifted.&lt;/p&gt;

&lt;p&gt;A normal block-hash comparison now makes the files look almost completely different.&lt;/p&gt;

&lt;p&gt;But the actual data is almost identical.&lt;/p&gt;

&lt;p&gt;Rsync searches for the old blocks at &lt;strong&gt;any offset&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;After seeing the extra &lt;code&gt;X&lt;/code&gt;, it can eventually discover:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AAAA
BBBB
CCCC
DDDD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;inside the new byte stream.&lt;/p&gt;

&lt;p&gt;The rolling checksum makes that search practical.&lt;/p&gt;




&lt;h1&gt;
  
  
  So Why Isn't the rsync Algorithm Everywhere?
&lt;/h1&gt;

&lt;p&gt;This is where things get interesting.&lt;/p&gt;

&lt;p&gt;The algorithm solves a very specific problem:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Two machines contain similar data, bandwidth matters, and the sender does not already have the receiver's version.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Many modern systems don't actually have this problem.&lt;/p&gt;

&lt;p&gt;They have easier problems.&lt;/p&gt;

&lt;p&gt;And easier problems often have better solutions.&lt;/p&gt;




&lt;h1&gt;
  
  
  Git Doesn't Need rsync
&lt;/h1&gt;

&lt;p&gt;Git already models data using content hashes.&lt;/p&gt;

&lt;p&gt;Very roughly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;blob   -&amp;gt; hash
tree   -&amp;gt; hash
commit -&amp;gt; hash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If both machines know object IDs, they can negotiate which objects are missing.&lt;/p&gt;

&lt;p&gt;Instead of asking:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which regions of this file happen to exist on the other machine?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Git can ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which objects do you already have?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Git also uses delta compression in packfiles, but that's a different layer of the problem.&lt;/p&gt;

&lt;p&gt;The architecture already provides information rsync has to discover dynamically.&lt;/p&gt;

&lt;p&gt;This illustrates an important principle:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A clever synchronization algorithm can become unnecessary if your data model makes identity explicit.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h1&gt;
  
  
  Software Updates Have an Easier Problem
&lt;/h1&gt;

&lt;p&gt;Suppose you're distributing an application.&lt;/p&gt;

&lt;p&gt;The server may have:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;version 1.0
version 1.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It can calculate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;diff(version_1_0, version_1_1)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ahead of time.&lt;/p&gt;

&lt;p&gt;Then users with version 1.0 download only the patch.&lt;/p&gt;

&lt;p&gt;Algorithms and formats such as binary delta encoding can be optimized specifically for this situation.&lt;/p&gt;

&lt;p&gt;Rsync solves something harder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sender has NEW
receiver has OLD

sender does NOT have OLD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the server already has both versions, there is often no reason to pay the complexity cost of rsync's remote block discovery.&lt;/p&gt;




&lt;h1&gt;
  
  
  Then Came Content-Defined Chunking
&lt;/h1&gt;

&lt;p&gt;This is perhaps the most interesting descendant of the same family of ideas.&lt;/p&gt;

&lt;p&gt;Modern backup and deduplication systems often use &lt;strong&gt;content-defined chunking&lt;/strong&gt;, or CDC.&lt;/p&gt;

&lt;p&gt;Instead of saying:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;split every 4 MB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;they determine chunk boundaries from the content.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;data stream
    |
rolling fingerprint
    |
boundary condition
    |
    v
chunk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You might end up with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ 2.8 MB ][ 5.1 MB ][ 3.7 MB ][ 4.4 MB ]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The chunks aren't the same size.&lt;/p&gt;

&lt;p&gt;Their boundaries depend on their contents.&lt;/p&gt;




&lt;h1&gt;
  
  
  Why Content-Defined Chunking Is Powerful
&lt;/h1&gt;

&lt;p&gt;Consider inserting some data near the beginning of a file.&lt;/p&gt;

&lt;p&gt;With fixed-size chunks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OLD:

[AAAA][BBBB][CCCC][DDDD]

NEW:

X[AAA][ABBB][BCCC][CDDD]...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything becomes misaligned.&lt;/p&gt;

&lt;p&gt;With content-defined chunking, the insertion temporarily disturbs the boundaries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OLD:

[ chunk A ][ chunk B ][ chunk C ][ chunk D ]

NEW:

[ changed... ][ chunk B ][ chunk C ][ chunk D ]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eventually the rolling fingerprint encounters the same content and starts selecting the same boundaries again.&lt;/p&gt;

&lt;p&gt;The streams &lt;strong&gt;resynchronize naturally&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This makes CDC extremely useful for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;backup systems&lt;/li&gt;
&lt;li&gt;deduplication&lt;/li&gt;
&lt;li&gt;content-addressed storage&lt;/li&gt;
&lt;li&gt;distributed storage&lt;/li&gt;
&lt;li&gt;container/image storage&lt;/li&gt;
&lt;li&gt;large artifact stores&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important idea escaped rsync.&lt;/p&gt;

&lt;p&gt;The exact protocol usually didn't.&lt;/p&gt;




&lt;h1&gt;
  
  
  rsync vs Content-Defined Chunking
&lt;/h1&gt;

&lt;p&gt;The two ideas look similar but solve somewhat different problems.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;rsync&lt;/th&gt;
&lt;th&gt;Content-defined chunking&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Main goal&lt;/td&gt;
&lt;td&gt;Remote synchronization&lt;/td&gt;
&lt;td&gt;Stable chunk identity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Boundaries&lt;/td&gt;
&lt;td&gt;Receiver's fixed blocks&lt;/td&gt;
&lt;td&gt;Determined by content&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rolling function&lt;/td&gt;
&lt;td&gt;Search for matching blocks&lt;/td&gt;
&lt;td&gt;Find chunk boundaries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Needs old file on sender&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Depends on system&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Great for&lt;/td&gt;
&lt;td&gt;Ad-hoc remote files&lt;/td&gt;
&lt;td&gt;Deduplication/storage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Persistent chunk index&lt;/td&gt;
&lt;td&gt;Not required&lt;/td&gt;
&lt;td&gt;Usually useful&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Rsync asks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Where does this receiver block occur in my file?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CDC asks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Where should this content naturally be divided?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Similar machinery.&lt;/p&gt;

&lt;p&gt;Different architecture.&lt;/p&gt;




&lt;h1&gt;
  
  
  The Network Also Changed
&lt;/h1&gt;

&lt;p&gt;When rsync was created, network bandwidth was much more precious relative to CPU and storage.&lt;/p&gt;

&lt;p&gt;Today we frequently have:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1 Gbit/s LAN
10 Gbit/s LAN
fast datacenter networks
CDNs
cheap object storage bandwidth
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Meanwhile, computing millions of hashes, maintaining indexes, performing random reads, and implementing complicated synchronization protocols isn't free.&lt;/p&gt;

&lt;p&gt;Sometimes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;just send the file
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;really is the better engineering decision.&lt;/p&gt;

&lt;p&gt;Especially when files are small.&lt;/p&gt;




&lt;h1&gt;
  
  
  Encryption and Compression Make Things Worse
&lt;/h1&gt;

&lt;p&gt;Rsync works best when similar logical content produces similar bytes.&lt;/p&gt;

&lt;p&gt;Compression destroys much of that property.&lt;/p&gt;

&lt;p&gt;A small modification to uncompressed data can change a large portion of a compressed stream.&lt;/p&gt;

&lt;p&gt;Encryption is even more hostile to byte-level similarity.&lt;/p&gt;

&lt;p&gt;Ideally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;similar plaintext
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;should produce:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;completely unrelated ciphertext
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So synchronizing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;database.gz
backup.tar.zst
encrypted-volume.img
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;may provide much less opportunity for rsync-style matching than synchronizing the underlying uncompressed data.&lt;/p&gt;

&lt;p&gt;Modern systems often solve this by moving deduplication &lt;strong&gt;before&lt;/strong&gt; compression or encryption.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;files
  |
CDC
  |
chunks
  |
deduplicate
  |
compress
  |
encrypt
  |
storage
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Architecture beats trying to recover similarity afterward.&lt;/p&gt;




&lt;h1&gt;
  
  
  The Bigger Lesson
&lt;/h1&gt;

&lt;p&gt;The fascinating part of rsync isn't really &lt;code&gt;rsync -av&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It's the decomposition of the problem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cheap rolling checksum
        |
candidate detection
        |
strong verification
        |
delta instructions
        |
minimal network transfer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The complete protocol remains particularly useful when:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sender has new file
receiver has similar old file
sender doesn't have old file
bandwidth is expensive
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a narrower situation than it initially appears.&lt;/p&gt;

&lt;p&gt;But the underlying ideas are very much alive.&lt;/p&gt;

&lt;p&gt;Modern systems use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;              rolling fingerprints
                      |
        +-------------+-------------+
        |                           |
        v                           v
      rsync                       CDC
        |                           |
remote block discovery      stable chunk boundaries
        |                           |
        v                           v
delta transfer          dedup / backup / CAS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So perhaps the right conclusion isn't:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Why did nobody reuse the rsync algorithm?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It's:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;They reused the important ideas and changed the architecture around them.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And that's often what happens to great algorithms.&lt;/p&gt;

&lt;p&gt;The original implementation becomes associated with one famous tool, while its ideas quietly become building blocks for the next generation of systems.&lt;/p&gt;

</description>
      <category>cli</category>
      <category>computerscience</category>
      <category>networking</category>
      <category>performance</category>
    </item>
    <item>
      <title>DatasetRT: Stop Rebuilding Datasets to Rebalance Training</title>
      <dc:creator>Vadym Stupakov</dc:creator>
      <pubDate>Fri, 16 Oct 2026 05:29:44 +0000</pubDate>
      <link>https://dev.to/redeyed/i-built-a-rust-powered-dataset-cache-because-training-data-keeps-changing-23l</link>
      <guid>https://dev.to/redeyed/i-built-a-rust-powered-dataset-cache-because-training-data-keeps-changing-23l</guid>
      <description>&lt;p&gt;You’re running another training experiment on the same dataset. The images are&lt;br&gt;
already processed, the features are computed, or the text is tokenized. Millions&lt;br&gt;
of records are sitting on disk, ready to use.&lt;/p&gt;

&lt;p&gt;This time, you want to rebalance a rare class. Or exclude low-quality samples.&lt;br&gt;
Or run on 1% of the data while debugging a model change.&lt;/p&gt;

&lt;p&gt;You also want your PyTorch loading workers to keep the GPU fed without filling&lt;br&gt;
RAM with copies of dataset metadata.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This article is for ML engineers and researchers who reuse large preprocessed&lt;br&gt;
datasets across training experiments and need to change how those samples are&lt;br&gt;
selected.&lt;/strong&gt; The examples use Python, Polars, and PyTorch; the cache itself is&lt;br&gt;
independent of the training framework.&lt;/p&gt;

&lt;p&gt;The problem becomes familiar once your dataset contains more than just an input&lt;br&gt;
and a target. You might have an image with annotations and preprocessing&lt;br&gt;
outputs, or a tokenized document with several arrays. Loading that whole record&lt;br&gt;
just to check its label or quality score is work your experiment shouldn’t need.&lt;/p&gt;

&lt;p&gt;The payloads haven’t changed. Your decisions about &lt;strong&gt;which records to train on&lt;/strong&gt;&lt;br&gt;
have.&lt;/p&gt;

&lt;p&gt;That distinction is the starting point for &lt;a href="https://github.com/Red-Eyed/DatasetRT" rel="noopener noreferrer"&gt;DatasetRT&lt;/a&gt;: a Rust-powered dataset cache for Python that keeps serialized samples and their sampling metadata separate.&lt;/p&gt;

&lt;p&gt;It lets you change the training population without rebuilding the payload cache.&lt;br&gt;
The useful part is the combination: Python records you already know how to&lt;br&gt;
serialize, separately queryable metadata, native sample reads, and shared mapped&lt;br&gt;
buffers for multiprocessing loaders.&lt;/p&gt;
&lt;h2&gt;
  
  
  One dataset, two connected parts
&lt;/h2&gt;

&lt;p&gt;Imagine a training record containing an image, its class label, and&lt;br&gt;
arrays produced during preprocessing. Your Python source packs that structure&lt;br&gt;
with &lt;code&gt;pickle&lt;/code&gt; or &lt;code&gt;dill&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That record might be large. Its label, split, quality score, and sampling&lt;br&gt;
weight are usually much smaller. Keeping those attributes queryable lets you&lt;br&gt;
ask for more examples of an underrepresented class without unpacking every&lt;br&gt;
image first. The same pattern applies to source categories in tokenized corpora.&lt;/p&gt;

&lt;p&gt;DatasetRT keeps both:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Payload store&lt;/th&gt;
&lt;th&gt;Metadata table&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Serialized sample bytes in indexed disk shards&lt;/td&gt;
&lt;td&gt;Sample identities, labels, splits, scores, and weights&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read when training needs a sample&lt;/td&gt;
&lt;td&gt;Used to analyze and select the training population&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unpacked by your Python code&lt;/td&gt;
&lt;td&gt;Exposed as a Polars DataFrame&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A &lt;code&gt;(cache_id, sample_id)&lt;/code&gt; identity connects a metadata row to its stored payload.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Polars operates on the metadata. Your pickled records stay in the payload store.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That makes a surprisingly useful question cheap to answer:&lt;/p&gt;

&lt;p&gt;“How many examples of each class do I have?”&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_metadata&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;label&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;agg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;samples&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You don’t need to unpickle every training record to count its label.&lt;/p&gt;

&lt;h2&gt;
  
  
  Changing an experiment shouldn’t mean copying a dataset
&lt;/h2&gt;

&lt;p&gt;Suppose your next training run should exclude low-quality records while keeping&lt;br&gt;
the original cache available for other experiments. With &lt;code&gt;label&lt;/code&gt;, &lt;code&gt;split&lt;/code&gt;, and&lt;br&gt;
&lt;code&gt;quality&lt;/code&gt; stored as metadata columns, the selection is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;selected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_metadata&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;split&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;train&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quality&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update_metadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;selected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cache files stay unchanged. Future dataset iterators use the selected population.&lt;/p&gt;

&lt;p&gt;For a quick development run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update_metadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;selected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;head&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You now have a smaller active dataset backed by the same immutable payloads.&lt;/p&gt;

&lt;p&gt;That matters when preprocessing is expensive, storage is large, or experiments keep changing the selection rules. You can express those rules directly instead of maintaining another collection of copied records.&lt;/p&gt;

&lt;p&gt;Analyze the metadata. Change the population. Reuse the bytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Class balancing becomes a table operation
&lt;/h2&gt;

&lt;p&gt;Consider a dataset with 9,000 examples of one class and 1,000 of another.&lt;/p&gt;

&lt;p&gt;With uniform per-record sampling, their expected shares are 90% and 10%.&lt;/p&gt;

&lt;p&gt;Assign each record the inverse of its class count:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;balanced&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;selected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with_columns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;over&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;label&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;weight&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update_metadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;balanced&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each class now has total weight 1, giving them equal expected sampling probability.&lt;/p&gt;

&lt;p&gt;Rust validates the accepted identities and weights before applying the update. Payloads remain untouched.&lt;/p&gt;

&lt;p&gt;The same mechanism can support hard-example sampling: compute scores in your training workflow, turn them into positive sampling weights, and update the population before creating the next iterator or loader.&lt;/p&gt;

&lt;p&gt;One detail deserves explicit attention: &lt;strong&gt;shuffled sampling uses replacement&lt;/strong&gt;. A sample can appear several times while another is absent. Equal expected class probabilities do not guarantee exactly equal class counts in every epoch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multiprocessing shouldn’t require another full metadata copy per worker
&lt;/h2&gt;

&lt;p&gt;Once your Python decode or augmentation step becomes a bottleneck, adding&lt;br&gt;
PyTorch loading processes is a reasonable experiment. It also creates another&lt;br&gt;
budget to watch: RAM.&lt;/p&gt;

&lt;p&gt;If each worker reconstructs a private metadata table and decoded index, worker count becomes a multiplier on those allocations.&lt;/p&gt;

&lt;p&gt;DatasetRT uses read-only, file-backed &lt;code&gt;mmap&lt;/code&gt; for the loader’s immutable Arrow metadata snapshot. Workers map the same snapshot, letting the operating system share its backing pages on the same host.&lt;/p&gt;

&lt;p&gt;Cache indexes are also mapped from their original files.&lt;/p&gt;

&lt;p&gt;This works with &lt;code&gt;spawn&lt;/code&gt;: workers open their mappings independently, so sharing does not depend on inheriting a live parent runtime.&lt;/p&gt;

&lt;p&gt;There are still private allocations. Weights, sampling state, decoded records, and prefetched batches consume memory per consumer. Separate metadata exports also have separate backing files.&lt;/p&gt;

&lt;p&gt;The saving is specific and useful: workers can reference shared metadata and index pages instead of each retaining a complete private copy of those buffers.&lt;/p&gt;
&lt;h2&gt;
  
  
  Rust handles the sample reads
&lt;/h2&gt;

&lt;p&gt;After selecting a sample, DatasetRT’s native runtime resolves its index entry and reads the payload using native workers and bounded prefetching.&lt;/p&gt;

&lt;p&gt;Python receives the serialized bytes and performs application-specific unpacking:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pickle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sample&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your source chooses the serialization format. DatasetRT doesn’t need to understand your nested dictionary, tensor layout, or domain object to store and retrieve its bytes.&lt;/p&gt;

&lt;p&gt;Keeping that boundary small lets the native reader focus on indexed access, ordering, and bounded work.&lt;/p&gt;

&lt;p&gt;Payload size matters when comparing readers: 10,000 small records and 10,000&lt;br&gt;
large images represent very different amounts of work. I measured &lt;strong&gt;random&lt;br&gt;
weighted reads plus unpickling&lt;/strong&gt;, and report the reconstructed image bytes per&lt;br&gt;
second so the amount of work is explicit.&lt;/p&gt;

&lt;p&gt;Each cached payload is a pickled dictionary containing a &lt;strong&gt;256 × 256 RGB NumPy&lt;br&gt;
array (&lt;code&gt;uint8&lt;/code&gt;)&lt;/strong&gt;, a label, and sample identity. Each reconstructed image occupies&lt;br&gt;
192 KiB. There are 4096 image records across two caches: 768 MiB of image data,&lt;br&gt;
plus pickle headers and the other dictionary fields.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This benchmark uses &lt;code&gt;shuffle=True&lt;/code&gt;: weighted multinomial sampling with&lt;br&gt;
replacement, not sequential reading.&lt;/strong&gt; Four equally populated classes have&lt;br&gt;
per-record weights &lt;code&gt;1:2:4:8&lt;/code&gt;, giving expected draw shares of approximately&lt;br&gt;
6.7%, 13.3%, 26.7%, and 53.3%. Each pass draws 4096 samples; repeats count as&lt;br&gt;
additional reads and reconstructions.&lt;/p&gt;

&lt;p&gt;Every delivered record goes through &lt;code&gt;pickle.loads()&lt;/code&gt; inside the timed consumer&lt;br&gt;
or loading worker. The benchmark validates the reconstructed image's shape,&lt;br&gt;
dtype, sample identity, and identifying pixels. It does not cache unpacked&lt;br&gt;
records between draws.&lt;/p&gt;

&lt;p&gt;These are medians of five trials per configuration, with case order rotated:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Configuration&lt;/th&gt;
&lt;th&gt;First pass: image data reconstructed (MiB/s)&lt;/th&gt;
&lt;th&gt;Second pass: image data reconstructed (MiB/s)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Native iteration&lt;/td&gt;
&lt;td&gt;6657&lt;/td&gt;
&lt;td&gt;6767&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PyTorch loader, zero loading workers&lt;/td&gt;
&lt;td&gt;6669&lt;/td&gt;
&lt;td&gt;6808&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PyTorch loader, one spawned worker&lt;/td&gt;
&lt;td&gt;1313&lt;/td&gt;
&lt;td&gt;6604&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PyTorch loader, two spawned workers&lt;/td&gt;
&lt;td&gt;1337&lt;/td&gt;
&lt;td&gt;11954&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Measurements used an Apple M5 with 24 GiB RAM, macOS 27.0.1, Python 3.11.15,&lt;br&gt;
PyTorch 2.14.1, and DatasetRT 0.4.6. Each consumer has two native reader threads;&lt;br&gt;
batch size and native prefetch capacity are both 64. Loading workers use &lt;code&gt;spawn&lt;/code&gt;,&lt;br&gt;
replicated weighted populations, two prefetched batches per worker, and automatic&lt;br&gt;
persistence between passes. Fresh readers with the same seed and topology&lt;br&gt;
reproduced their sample traces; successive passes continued the sampling stream.&lt;/p&gt;

&lt;p&gt;The timer covers iterator creation through exhaustion: sample reading,&lt;br&gt;
unpickling, validation, batching, and consumption. The first-pass figures include&lt;br&gt;
loading-worker startup; the second pass reuses persistent workers. Dataset and&lt;br&gt;
loader construction, imports, fixture creation, and metadata-weight setup are&lt;br&gt;
outside these timers. MiB/s is reconstructed image bytes divided by total pass&lt;br&gt;
time, including first-batch latency; 1 MiB is 1,048,576 bytes. The raw report also&lt;br&gt;
retains serialized-payload byte counts for the steady interval after the first&lt;br&gt;
batch.&lt;/p&gt;

&lt;p&gt;The images are &lt;strong&gt;synthetic RGB arrays&lt;/strong&gt;, not photographs or encoded JPEG/PNG&lt;br&gt;
files. Unpickling reconstructs the dictionary and NumPy array; this benchmark&lt;br&gt;
does not include codec decoding, augmentation, GPU transfer, or model execution.&lt;br&gt;
Loading workers send compact validation results back to the parent rather than&lt;br&gt;
full image arrays, so the table does not measure full-image IPC throughput.&lt;/p&gt;

&lt;p&gt;The dataset fits in RAM and the filesystem cache is warm. These are logical&lt;br&gt;
read-and-reconstruction rates, including replacement draws—not physical disk&lt;br&gt;
bandwidth. A benchmark of compressed images or a complete training pipeline&lt;br&gt;
would measure a different workload.&lt;/p&gt;

&lt;p&gt;The one-worker result is worth keeping in the table. It was slower than the zero-worker baseline. More processes also increased startup time and memory.&lt;/p&gt;

&lt;p&gt;Fast native reads give you a useful foundation. Your actual decoding workload determines whether additional Python workers help.&lt;/p&gt;
&lt;h2&gt;
  
  
  The workflow I wanted
&lt;/h2&gt;

&lt;p&gt;The combination is what makes DatasetRT useful:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Preprocess and serialize records once.&lt;/li&gt;
&lt;li&gt;Keep their sampling attributes in a separate metadata table.&lt;/li&gt;
&lt;li&gt;Analyze, filter, and reweight that table as experiments change.&lt;/li&gt;
&lt;li&gt;Read selected payloads through the Rust runtime.&lt;/li&gt;
&lt;li&gt;Share mapped metadata and indexes across loader workers where possible.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There is one deliberate responsibility left with the application: cache reuse follows the source name. If preprocessing or source contents change, use a new source name or cache root. DatasetRT does not automatically fingerprint your preprocessing code.&lt;/p&gt;

&lt;p&gt;DatasetRT is worth evaluating when preprocessing is expensive, cached records&lt;br&gt;
are large, and your experiment loop repeatedly changes subsets or sampling&lt;br&gt;
weights. It is especially relevant when multiple loading processes make private&lt;br&gt;
metadata and index copies costly.&lt;/p&gt;

&lt;p&gt;If your data is small enough to keep in memory and a simple dataset plus sampler&lt;br&gt;
already does the job, the extra cache layer may buy you little. The intended&lt;br&gt;
user is the person who keeps revisiting the same expensive records and wants the&lt;br&gt;
next experiment to start with a metadata change rather than another data-preparation&lt;br&gt;
job.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/Red-Eyed/DatasetRT" rel="noopener noreferrer"&gt;Explore DatasetRT on GitHub&lt;/a&gt; or install the package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv add dataset-rt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>machinelearning</category>
      <category>python</category>
      <category>rust</category>
      <category>deeplearning</category>
    </item>
    <item>
      <title>git-sfs: Large File Storage Without the LFS Server</title>
      <dc:creator>Vadym Stupakov</dc:creator>
      <pubDate>Thu, 07 May 2026 20:42:25 +0000</pubDate>
      <link>https://dev.to/redeyed/git-sfs-large-file-storage-without-the-lfs-server-5cco</link>
      <guid>https://dev.to/redeyed/git-sfs-large-file-storage-without-the-lfs-server-5cco</guid>
      <description>&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;You added a 2 GB dataset to a repo. Now &lt;code&gt;git clone&lt;/code&gt; takes 10 minutes, CI downloads the full history on every run, and GitHub is billing you per GB transferred. You switch to Git LFS - and now you need a server, a token, and a storage plan. You try DVC - and now you need Python, a pipeline config, and lock files that conflict on every PR.&lt;/p&gt;

&lt;p&gt;None of this is the actual problem. The actual problem is: large files don't belong in Git objects. Everything else is overhead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Meet git-sfs
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;SFS&lt;/strong&gt; stands for &lt;strong&gt;Symbolic File Storage&lt;/strong&gt;. The name is deliberate - it's Git LFS with the &lt;code&gt;L&lt;/code&gt; swapped for &lt;code&gt;S&lt;/code&gt;. Git LFS replaces large files with opaque pointer files and routes bytes through a proprietary server protocol. &lt;code&gt;git-sfs&lt;/code&gt; replaces large files with plain symlinks that Git already understands natively, and routes bytes through &lt;code&gt;rclone&lt;/code&gt; to any remote you already have.&lt;/p&gt;

&lt;p&gt;No new protocol. No server. No pointer file format to decode. Just symlinks.&lt;/p&gt;

&lt;h2&gt;
  
  
  How It Works
&lt;/h2&gt;

&lt;p&gt;The model is three sentences:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Git tracks symlinks.
git-sfs stores file bytes.
rclone moves files.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you run &lt;code&gt;git-sfs add data/train-000.tar.zst&lt;/code&gt;, here's what happens:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The file is hashed with SHA-256&lt;/li&gt;
&lt;li&gt;Bytes are stored in &lt;code&gt;&amp;lt;cache&amp;gt;/files/sha256/ab/&amp;lt;hash&amp;gt;&lt;/code&gt; - write-once, read-only&lt;/li&gt;
&lt;li&gt;The original path becomes a relative Git symlink pointing into the cache
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;data/train-000.tar.zst -&amp;gt; ../.git-sfs/cache/files/sha256/ab/&amp;lt;hash&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;data/train-000.tar.zst&lt;/code&gt; opens normally. Git commits a 70-byte symlink. Your repo stays fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Not Git LFS?
&lt;/h2&gt;

&lt;p&gt;Git LFS solves the storage problem but adds a &lt;em&gt;server&lt;/em&gt; problem. You need an LFS endpoint, pay per-GB transfer fees on GitHub, and the pointer-file format is an opaque internal detail.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;git-sfs&lt;/code&gt; remotes are plain &lt;code&gt;rclone&lt;/code&gt; destinations - S3, GCS, Azure Blob, Backblaze B2, SFTP, a local path, anything &lt;code&gt;rclone&lt;/code&gt; supports. You can &lt;code&gt;rclone ls&lt;/code&gt; your remote and see exactly what's there. No magic.&lt;/p&gt;

&lt;h2&gt;
  
  
  One Binary, No Runtime
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;git-sfs&lt;/code&gt; is written in Go. It ships as a single static binary - no Python environment, no runtime, no version conflicts. Drop it on any machine and it runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Start
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Install (macOS/Linux, arm64 and x86_64)&lt;/span&gt;
curl &lt;span class="nt"&gt;-LsSf&lt;/span&gt; https://github.com/Red-Eyed/git-sfs/releases/latest/download/install.sh | sh

&lt;span class="c"&gt;# Init and configure&lt;/span&gt;
git-sfs init        &lt;span class="c"&gt;# creates .git-sfs/config.toml&lt;/span&gt;
&lt;span class="c"&gt;# edit config.toml: set remote backend, path, rclone config&lt;/span&gt;
git-sfs setup       &lt;span class="c"&gt;# bind local cache&lt;/span&gt;

&lt;span class="c"&gt;# Add files&lt;/span&gt;
git-sfs add data/
git add .git-sfs/config.toml data/
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"track datasets"&lt;/span&gt;

&lt;span class="c"&gt;# Sync to remote&lt;/span&gt;
git-sfs push
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On another machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone &amp;lt;repo&amp;gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; &amp;lt;repo&amp;gt;
git-sfs setup
git-sfs pull        &lt;span class="c"&gt;# download only what you need&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also pull a subset - useful when one machine only needs validation data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git-sfs pull data/validation/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Safety by Design
&lt;/h2&gt;

&lt;p&gt;Data loss in a dataset management tool is unacceptable, so &lt;code&gt;git-sfs&lt;/code&gt; has a few hard rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hash-verify at every boundary&lt;/strong&gt; - after hashing, after download, after copy. A corrupted file is rejected, not silently accepted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Atomic writes&lt;/strong&gt; - temp file + rename everywhere. An interrupted push or pull never leaves a partial file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache files are immutable&lt;/strong&gt; - write-once, then write-protected. Accidental overwrites are impossible.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;verify&lt;/code&gt; command is designed for CI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git-sfs verify              &lt;span class="c"&gt;# presence check (fast)&lt;/span&gt;
git-sfs verify &lt;span class="nt"&gt;--with-integrity&lt;/span&gt; data/  &lt;span class="c"&gt;# rehash cached + remote files (thorough)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;doctor&lt;/code&gt; checks your entire setup - config, cache, rclone binary, remote connectivity - in one shot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git-sfs doctor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What's Different from dvc, git-annex, etc.?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Requires&lt;/th&gt;
&lt;th&gt;Remote&lt;/th&gt;
&lt;th&gt;PR-friendly?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Git LFS&lt;/td&gt;
&lt;td&gt;LFS server&lt;/td&gt;
&lt;td&gt;proprietary protocol&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DVC&lt;/td&gt;
&lt;td&gt;Python, pipelines&lt;/td&gt;
&lt;td&gt;S3/GCS/etc via SDK&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;git-annex&lt;/td&gt;
&lt;td&gt;Haskell runtime&lt;/td&gt;
&lt;td&gt;many backends&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;git-sfs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;rclone&lt;/td&gt;
&lt;td&gt;anything rclone supports&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;DVC&lt;/strong&gt; stores large file metadata in &lt;code&gt;.dvc&lt;/code&gt; sidecar files and a &lt;code&gt;dvc.lock&lt;/code&gt; that encode pipeline state. When two branches touch the same dataset, merging those lock files creates conflicts that have no meaningful resolution in a pull request - reviewers see &lt;code&gt;.dvc&lt;/code&gt; diffs, not data diffs, and the merge problem is fundamentally DVC's, not Git's.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;git-annex&lt;/strong&gt; stores its metadata in a separate orphan branch (&lt;code&gt;git-annex&lt;/code&gt;). That branch never appears in a normal &lt;code&gt;git log&lt;/code&gt; or PR diff, so reviewers have no visibility into what large files changed or why. Merging the annex branch is a separate out-of-band operation that GitHub PRs don't surface at all.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;git-sfs&lt;/code&gt; tracks only plain relative symlinks. A PR diff shows exactly which files were added, removed, or renamed - the same way any other file change looks in Git. Reviewers can approve or reject dataset changes with full context, and there are no sidecar files or hidden branches to reconcile.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;git-sfs&lt;/code&gt; has no pipelines, no Python, no manifests, no committed lock files. The Git tree is the file list. The cache is a plain directory. The remote is whatever &lt;code&gt;rclone&lt;/code&gt; can reach.&lt;/p&gt;

&lt;h2&gt;
  
  
  Concurrency and Partial Pulls
&lt;/h2&gt;

&lt;p&gt;Large dataset workflows often mean hundreds of files if not millions. &lt;code&gt;git-sfs&lt;/code&gt; runs add, push, and pull with a configurable worker pool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="c"&gt;# .git-sfs/config.toml&lt;/span&gt;
&lt;span class="nn"&gt;[settings]&lt;/span&gt;
&lt;span class="py"&gt;n_jobs&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And partial pulls let teams share a repo where different machines only materialize what they actually use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git-sfs pull data/train/         &lt;span class="c"&gt;# only training split&lt;/span&gt;
git-sfs pull data/checkpoints/   &lt;span class="c"&gt;# only model weights&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Source
&lt;/h2&gt;

&lt;p&gt;The project is open source under MIT: &lt;a href="https://github.com/Red-Eyed/git-sfs" rel="noopener noreferrer"&gt;github.com/Red-Eyed/git-sfs&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Feedback and issues welcome. If you're managing large files in Git and the LFS server tax is getting old, give it a try.&lt;/p&gt;

</description>
      <category>git</category>
      <category>devops</category>
      <category>go</category>
      <category>lfs</category>
    </item>
  </channel>
</rss>
