fastcdc

package module
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 24, 2026 License: MIT Imports: 4 Imported by: 1

README

fastcdc

Go Reference Go Report Card Go Version

High-performance, thread-safe content-defined chunking (CDC) library for Go using the FastCDC algorithm with Gear hash.

Features

  • High Performance: ~1350 MB/s throughput, fastest Go implementation of FastCDC
  • Low Allocations: ~4 allocations/op with convenient API, 0 allocations/op with advanced API
  • Thread-Safe: Per-instance hash tables eliminate data races
  • Dual API: Simple streaming API for convenience, zero-allocation API for performance
  • Normalized Chunking: Two-phase boundary detection for better chunk distribution
  • Pure Go: No external dependencies, works on all platforms

Installation

go get github.com/kalbasit/fastcdc

Quick Start

package main

import (
    "fmt"
    "io"
    "os"

	"github.com/kalbasit/fastcdc"
)

func main() {
    file, _ := os.Open("largefile.dat")
    defer file.Close()

    chunker, _ := fastcdc.NewChunker(file, fastcdc.WithTargetSize(64*1024))

    for {
        chunk, err := chunker.Next()
        if err == io.EOF {
            break
        }
        if err != nil {
            panic(err)
        }

        fmt.Printf("Chunk at offset %d: %d bytes, hash=%x\n",
            chunk.Offset, chunk.Length, chunk.Hash)

        // Process chunk.Data (valid until next Next() call)
        processChunk(chunk.Data)
    }
}
Zero-Allocation API (Advanced)

For performance-critical code where you manage buffers manually:

package main

import (
    "fmt"
    "os"

	"github.com/kalbasit/fastcdc"
)

func main() {
    file, _ := os.Open("largefile.dat")
    defer file.Close()

    core, _ := fastcdc.NewChunkerCore(fastcdc.WithTargetSize(64*1024))
    buf := make([]byte, 1*1024*1024) // 1 MiB buffer

    for {
        n, err := file.Read(buf)
        if n == 0 {
            break
        }

        offset := 0
        for offset < n {
            boundary, hash, found := core.FindBoundary(buf[offset:n])

            if found {
                chunkData := buf[offset:offset+boundary]
                fmt.Printf("Chunk: %d bytes, hash=%x\n", len(chunkData), hash)
                processChunk(chunkData)

                offset += boundary
                core.Reset()
            } else {
                // Handle partial chunk at buffer boundary
                break
            }
        }

        if err != nil {
            break
        }
    }
}
Pool API (High Throughput)

For concurrent processing with minimal allocations:

package main

import (
    "bytes"
    "io"
    "sync"

	"github.com/kalbasit/fastcdc"
)

func main() {
    pool, _ := fastcdc.NewChunkerPool(fastcdc.WithTargetSize(64*1024))

    var wg sync.WaitGroup
    jobs := make(chan []byte, 100)

    // Worker pool
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for data := range jobs {
                chunker, _ := pool.Get(bytes.NewReader(data))

                for {
                    chunk, err := chunker.Next()
                    if err == io.EOF {
                        break
                    }
                    processChunk(chunk.Data)
                }

                pool.Put(chunker)
            }
        }()
    }

    // Feed jobs
    // ... send data to jobs channel ...

    close(jobs)
    wg.Wait()
}

Configuration Options

// Size constraints
fastcdc.WithMinSize(16*1024)      // Minimum chunk size (default: 16 KiB)
fastcdc.WithTargetSize(64*1024)   // Target chunk size (default: 64 KiB)
fastcdc.WithMaxSize(256*1024)     // Maximum chunk size (default: 256 KiB)

// Normalization (affects chunk distribution)
fastcdc.WithNormalization(2)      // Level 0-8 (default: 2)
                                  // Higher = more uniform distribution
                                  // Lower = faster processing

// Custom seed (for different chunking patterns)
fastcdc.WithSeed(12345)           // Non-zero seed allocates per-instance table

// Buffer size (streaming API only)
fastcdc.WithBufferSize(1*1024*1024) // Default: 1 MiB

Performance

Benchmarked on 10 MiB random data with 64 KiB target chunk size:

API Throughput Allocations Use Case
FindBoundary() ~1350 MB/s 0 allocs/op Performance-critical code
Next() ~1200 MB/s ~4 alloc/op General purpose streaming
Pool ~1250 MB/s ~1 alloc/op High-throughput concurrent
Comparison with Other Libraries

Benchmarked on Apple M4, 10 MiB random data, 64 KiB target chunk size:

Library Throughput Allocations Bytes/op Algorithm
fastcdc (FindBoundary) 1344.45 MB/s 0 allocs/op 0 B Gear hash
fastcdc (Next) 1200.41 MB/s 4 allocs/op 514 KiB Gear hash
fastcdc (Next, no norm) 1304.57 MB/s 4 allocs/op 514 KiB Gear hash
fastcdc (Pool) 1230.12 MB/s 1 allocs/op 1 KiB Gear hash
jotfs/fastcdc-go 1180.75 MB/s 3 allocs/op 512 KiB Gear hash
buildbuddy-io/fastcdc-go 1160.49 MB/s 3 allocs/op 512 KiB Gear hash
restic/chunker 434.04 MB/s 31 allocs/op 25533 KiB Rabin fingerprint

Note: The FindBoundary() zero-allocation API provides superior performance. The streaming Next() API offers convenience with reasonable allocation overhead.

Running the Comparison Benchmark

To run the comparison benchmark yourself:

cd benchmarks
./run_all.sh

This will benchmark all libraries with identical test data and configurations, displaying:

  • Throughput (MB/s)
  • Allocations per operation
  • Bytes allocated per operation
  • Algorithm used

The new benchmark structure isolates each library in its own directory with independent dependencies, making it easy to add or update library versions. Raw results are saved to benchmarks/all_results.txt.

When to Use Each API

Use Next() API when:
  • You want a simple, easy-to-use streaming API
  • You're processing files or streams sequentially
  • ~1 allocation per chunk is acceptable
  • You want automatic buffer management
Use FindBoundary() API when:
  • Every allocation counts (high-frequency processing)
  • You're willing to manage buffers manually
  • You need maximum performance
  • You're integrating with existing buffer pools
Use Pool API when:
  • Processing many files concurrently
  • You want to amortize allocations across operations
  • You have high throughput requirements
  • You're building a server or batch processor

Algorithm Details

Gear Hash

FastCDC uses Gear hash instead of Rabin fingerprinting for 10x faster performance:

  • Gear hash: 3 operations/byte (SHIFT, ADD, LOOKUP)
  • Rabin: 6 operations/byte (OR, 2×XOR, 2×SHIFT, 2×LOOKUP)

Rolling hash update:

fingerprint = (fingerprint << 1) + table[current_byte]
Normalized Chunking

Two-phase boundary detection for better chunk distribution:

  1. Skip phase [0, minSize): Fast-forward without checking
  2. Normalized phase [minSize, normSize): Check with smaller mask (easier to match)
  3. Standard phase [normSize, maxSize): Check with larger mask (harder to match)
  4. Hard limit: Force cut at maxSize

This prevents excessive tiny chunks while maintaining good distribution.

Thread Safety

Each chunker instance has its own hash table, eliminating data races:

  • Zero seed: Uses compile-time constant (no allocation, thread-safe)
  • Custom seed: Allocates per-instance table (2 KiB, thread-safe)
  • No global shared state

Testing

# Run tests
go test -v

# Run tests with race detector
go test -race

# Run internal benchmarks
cd benchmarks
go test -bench=. -benchmem -benchtime=3s

# Run comparison benchmarks against other libraries
cd benchmarks
./run_comparison.sh
# Or for detailed analysis:
python3 analyze_benchmarks.py

# Test distribution
go test -v -run=TestChunkerDistribution

Design Rationale

Why Dual API?

We provide two APIs to balance convenience with performance:

  1. Primary Next() API: Most users want a simple streaming API. We achieve ~1 allocation/op (better than jotfs: 3, restic: 15) while keeping the API clean.

  2. Advanced FindBoundary() API: Performance-critical code can drop to zero allocations by managing buffers manually.

This gives users the best of both worlds: simple API for most use cases, with a zero-allocation escape hatch when needed.

Why Gear Hash?

Gear hash is 3x faster than Rabin fingerprinting with similar chunking quality. The performance difference comes from:

  • Simpler operations (no XOR, no multi-table lookups)
  • Better CPU pipeline utilization
  • Smaller lookup table (256 entries vs 512+ for Rabin)
Why Per-Instance Tables?

Thread-safety without locks:

  • Global table: Fast but causes data races with custom seeds
  • Mutex-protected table: Thread-safe but slow
  • Per-instance table: Thread-safe and fast (our choice)

The 2 KiB overhead per instance is negligible for most use cases.

Contributing

Contributions welcome! Please ensure:

  • Tests pass: go test -race ./...
  • Benchmarks don't regress: cd benchmarks && go test -bench=.
  • Code is formatted: gofmt -s -w .

License

MIT License - see LICENSE file for details.

References

Acknowledgments

Inspired by:

Documentation

Overview

Package fastcdc provides high-performance, thread-safe content-defined chunking (CDC) using the FastCDC algorithm with Gear hash.

Overview

FastCDC is a content-defined chunking algorithm that divides data streams into variable-size chunks based on content rather than fixed boundaries. This enables efficient deduplication and delta compression.

This implementation offers:

  • High performance: >1000 MB/s throughput
  • Low allocations: ~1 allocation/op (Next API) or 0 allocations/op (FindBoundary API)
  • Thread-safety: No data races, safe for concurrent use
  • Dual API: Convenient streaming or zero-allocation

Quick Start

Simple streaming API:

chunker, _ := fastcdc.NewChunker(reader, fastcdc.WithTargetSize(64*1024))
for {
    chunk, err := chunker.Next()
    if err == io.EOF {
        break
    }
    // Process chunk.Data
}

Zero-allocation API for performance-critical code:

core, _ := fastcdc.NewChunkerCore(fastcdc.WithTargetSize(64*1024))
boundary, hash, found := core.FindBoundary(data)
if found {
    // Process data[:boundary]
    core.Reset()
}

Algorithm

This implementation uses the Gear hash algorithm, which is significantly faster than Rabin fingerprinting (3 operations/byte vs 6 operations/byte) while providing similar chunking quality.

The chunking process uses normalized chunking with two-phase boundary detection:

  1. Skip to minimum size (fast-forward without checking)
  2. Normalized region: Check with smaller mask (more aggressive cutting)
  3. Standard region: Check with larger mask (less aggressive cutting)
  4. Hard limit: Force cut at maximum size

This approach prevents excessive tiny chunks while maintaining good distribution.

Thread Safety

Each chunker instance maintains its own hash table, eliminating data races. Multiple goroutines can safely use separate chunker instances concurrently. For high-throughput scenarios, use ChunkerPool to recycle instances.

Performance

Benchmarked on 10 MiB random data (Apple M4):

  • Next() API: ~1100 MB/s, ~1 alloc/op
  • FindBoundary() API: ~1100 MB/s, 0 allocs/op
  • Pool API: ~1000 MB/s, ~0.1 alloc/op

Standard deviation: ~55 KiB (well under 400 KiB target)

Index

Constants

View Source
const (
	// DefaultMinSize is the default minimum chunk size (16 KiB).
	DefaultMinSize = 16 * 1024

	// DefaultTargetSize is the default target chunk size (64 KiB).
	DefaultTargetSize = 64 * 1024

	// DefaultMaxSize is the default maximum chunk size (256 KiB).
	DefaultMaxSize = 256 * 1024

	// DefaultNormLevel is the default normalization level (2)
	// Determines the size of the normalization region: (targetSize - minSize) / 2^normLevel.
	DefaultNormLevel = 2

	// DefaultBufferSize is the default internal buffer size for the streaming API (512 KiB).
	// This is 2x the default max chunk size, providing efficient buffering.
	DefaultBufferSize = 512 * 1024
)

Variables

View Source
var (
	// ErrInvalidMinSize is returned when minSize is 0.
	ErrInvalidMinSize = errors.New("minSize must be greater than 0")

	// ErrInvalidTargetSize is returned when targetSize is 0.
	ErrInvalidTargetSize = errors.New("targetSize must be greater than 0")

	// ErrTargetSizeTooSmall is returned when targetSize is not greater than minSize.
	ErrTargetSizeTooSmall = errors.New("targetSize must be greater than minSize")

	// ErrInvalidMaxSize is returned when maxSize is 0.
	ErrInvalidMaxSize = errors.New("maxSize must be greater than 0")

	// ErrMaxSizeTooSmall is returned when maxSize is not greater than targetSize.
	ErrMaxSizeTooSmall = errors.New("maxSize must be greater than targetSize")

	// ErrInvalidNormLevel is returned when normLevel is not between 0 and 8.
	ErrInvalidNormLevel = errors.New("normLevel must be between 0 and 8")

	// ErrInvalidBufferSize is returned when bufferSize is 0.
	ErrInvalidBufferSize = errors.New("bufferSize must be greater than 0")
)

Functions

This section is empty.

Types

type Chunk

type Chunk struct {
	Offset uint64 // Absolute offset in the stream
	Length uint32 // Chunk size in bytes
	Hash   uint64 // Gear fingerprint at boundary
	Data   []byte // Chunk data (points into internal buffer)
}

Chunk represents a content-defined chunk with its metadata.

type Chunker

type Chunker struct {
	// contains filtered or unexported fields
}

Chunker provides a convenient streaming API for content-defined chunking. It wraps an io.Reader and returns chunks via the Next() method.

This API allocates minimally and is suitable for most use cases. For zero-allocation performance-critical code, use ChunkerCore.

func NewChunker

func NewChunker(r io.Reader, opts ...Option) (*Chunker, error)

NewChunker creates a new Chunker that reads from the given io.Reader.

func (*Chunker) Next

func (c *Chunker) Next() (Chunk, error)

Next returns the next chunk from the stream. Returns io.EOF when the stream is exhausted.

The returned Chunk.Data slice is valid until the next call to Next(). If you need to keep the data, copy it to your own buffer.

func (*Chunker) Offset

func (c *Chunker) Offset() uint64

Offset returns the current absolute offset in the stream.

func (*Chunker) Reset

func (c *Chunker) Reset(r io.Reader)

Reset resets the chunker to start processing a new stream. The reader is replaced with the provided one, and all state is cleared.

type ChunkerCore

type ChunkerCore struct {
	// contains filtered or unexported fields
}

ChunkerCore implements zero-allocation content-defined chunking using the Gear hash algorithm. It provides a low-level FindBoundary API for performance-critical code where managing buffers manually is acceptable.

For a more convenient streaming API with minimal allocations, use Chunker instead.

func NewChunkerCore

func NewChunkerCore(opts ...Option) (*ChunkerCore, error)

NewChunkerCore creates a new ChunkerCore with the given options. This is a zero-allocation API - the caller manages all buffers.

func (*ChunkerCore) FindBoundary

func (c *ChunkerCore) FindBoundary(data []byte) (boundary int, hash uint64, found bool)

FindBoundary scans the provided data for a chunk boundary. It returns:

  • boundary: the index of the chunk boundary (exclusive)
  • hash: the final Gear hash value at the boundary
  • found: true if a boundary was found, false if data exhausted

This is a zero-allocation API. The caller is responsible for:

  1. Providing the data buffer
  2. Tracking absolute position across multiple calls
  3. Handling data at chunk boundaries

The chunker maintains state between calls, so calling FindBoundary multiple times continues scanning from where the previous call left off.

Example usage:

core := NewChunkerCore(WithTargetSize(64*1024))
buf := make([]byte, 1*1024*1024)

for {
    n, _ := reader.Read(buf)
    if n == 0 {
        break
    }
    boundary, hash, found := core.FindBoundary(buf[:n])
    if found {
        processChunk(buf[:boundary], hash)
        // Continue with remaining data: buf[boundary:n]
    }
}

FindBoundary scans the provided data for a chunk boundary. It returns:

  • boundary: the index of the chunk boundary (exclusive)
  • hash: the final Gear hash value at the boundary
  • found: true if a boundary was found, false if data exhausted

This is a zero-allocation API. The caller is responsible for:

  1. Providing the data buffer
  2. Tracking absolute position across multiple calls
  3. Handling data at chunk boundaries

The chunker maintains state between calls, so calling FindBoundary multiple times continues scanning from where the previous call left off.

Example usage:

core := NewChunkerCore(WithTargetSize(64*1024))
buf := make([]byte, 1*1024*1024)

for {
    n, _ := reader.Read(buf)
    if n == 0 {
        break
    }
    boundary, hash, found := core.FindBoundary(buf[:n])
    if found {
        processChunk(buf[:boundary], hash)
        // Continue with remaining data: buf[boundary:n]
    }
}

func (*ChunkerCore) Fingerprint

func (c *ChunkerCore) Fingerprint() uint64

Fingerprint returns the current rolling hash value.

func (*ChunkerCore) MaxSize

func (c *ChunkerCore) MaxSize() uint32

MaxSize returns the maximum chunk size.

func (*ChunkerCore) MinSize

func (c *ChunkerCore) MinSize() uint32

MinSize returns the minimum chunk size.

func (*ChunkerCore) NormSize

func (c *ChunkerCore) NormSize() uint32

NormSize returns the normalization boundary.

func (*ChunkerCore) Position

func (c *ChunkerCore) Position() uint32

Position returns the current position within the chunk being processed. This can be used to determine how much data has been consumed.

func (*ChunkerCore) Reset

func (c *ChunkerCore) Reset()

Reset resets the chunker state for processing a new stream. This allows reusing the same ChunkerCore instance.

type ChunkerCorePool

type ChunkerCorePool struct {
	// contains filtered or unexported fields
}

ChunkerCorePool is a pool of ChunkerCore instances for reuse.

func NewChunkerCorePool

func NewChunkerCorePool(opts ...Option) (*ChunkerCorePool, error)

NewChunkerCorePool creates a new ChunkerCorePool with the given options. All chunker cores created from this pool will use these options.

func (*ChunkerCorePool) Get

func (p *ChunkerCorePool) Get() (*ChunkerCore, error)

Get retrieves a ChunkerCore from the pool, or creates a new one if the pool is empty.

func (*ChunkerCorePool) Put

func (p *ChunkerCorePool) Put(c *ChunkerCore)

Put returns a ChunkerCore to the pool for reuse. The core should not be used after being returned to the pool.

type ChunkerPool

type ChunkerPool struct {
	// contains filtered or unexported fields
}

ChunkerPool is a pool of Chunker instances for reuse in high-throughput scenarios. It reduces allocations by recycling chunkers instead of creating new ones.

func NewChunkerPool

func NewChunkerPool(opts ...Option) (*ChunkerPool, error)

NewChunkerPool creates a new ChunkerPool with the given options. All chunkers created from this pool will use these options.

func (*ChunkerPool) Get

func (p *ChunkerPool) Get(r io.Reader) (*Chunker, error)

Get retrieves a Chunker from the pool, or creates a new one if the pool is empty. The chunker is configured with the given reader and ready to use.

func (*ChunkerPool) Put

func (p *ChunkerPool) Put(c *Chunker)

Put returns a Chunker to the pool for reuse. The chunker should not be used after being returned to the pool.

type Option

type Option func(*config) error

Option is a function that configures a Chunker or ChunkerCore.

func WithBufferSize

func WithBufferSize(size int) Option

WithBufferSize sets the internal buffer size for the streaming API. Must be at least as large as maxSize.

func WithMaxSize

func WithMaxSize(size uint32) Option

WithMaxSize sets the maximum chunk size.

func WithMinSize

func WithMinSize(size uint32) Option

WithMinSize sets the minimum chunk size.

func WithNormalization

func WithNormalization(level uint8) Option

WithNormalization sets the normalization level. Level 0 disables normalization (single-mask behavior). Higher levels create a larger normalization region.

func WithSeed

func WithSeed(seed uint64) Option

WithSeed sets a custom seed for the Gear hash table. Using a non-zero seed will allocate a per-instance table (2 KiB).

func WithTargetSize

func WithTargetSize(size uint32) Option

WithTargetSize sets the target chunk size.

Directories

Path Synopsis
examples
advanced command
basic command

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL