Schwadler — The Fastest Ruby Gem Bundler

Schwadler

Drop-in Bundler replacement. 4-10x faster.

Real-World Benchmarks

Project Gems Bundler Schwadler Speedup
Discourse 317 9.87s 2.07s 4.8x
Mastodon 370 9.47s 2.22s 4.3x
Rails 8 App 322 9.55s 1.82s 5.3x
Cached run any ~3s 118ms 25x

Installation

# Install via cargo
cargo install schwadler

# Or download binary
curl -fsSL https://schwadler.schwadlabs.io/install.sh | sh

Usage

# Instead of: bundle lock
schwadl lock

# Instead of: bundle install
schwadl install

# Check cache status
schwadl cache stats

🦀 Built in Rust

Native speed. No Ruby startup overhead. Async I/O with Tokio.

⚡ Parallel Fetching

Fetches gem metadata concurrently. HTTP/2 connection pooling.

🧠 Smart Caching

Persistent cache with ETag support. Repeat runs in milliseconds.

📦 Drop-in Compatible

Generates standard Gemfile.lock. Works with existing tooling.

🎯 PubGrub Resolver

Same algorithm as Cargo and Dart. Battle-tested and fast.

🔧 Compact Index

Uses RubyGems compact index for minimal network overhead.

Why Schwadler?

Bundler is great, but slow. It's written in Ruby, which means Ruby startup time on every run. It fetches metadata sequentially. It doesn't cache aggressively.

Schwadler proves that gem resolution can be fast. Really fast. Built in a weekend to show what's possible.

How It Works

A technical deep-dive into Schwadler's architecture and optimizations

1. Problem Analysis

Dependency resolution is fundamentally an NP-complete problem (boolean satisfiability). However, real-world package ecosystems exhibit structure that makes practical resolution tractable. The primary bottleneck in Bundler is not algorithmic complexity—it is I/O latency.

Profiling reveals that Bundler spends ~85% of resolution time waiting on network requests to RubyGems.org. Each gem lookup incurs a full HTTP round-trip. For a Rails application with 300+ dependencies, this compounds to seconds of blocked I/O.

# Bundler's resolution pattern (simplified)
resolve(A) → fetch(A) → wait 50ms → resolve(B) → fetch(B) → wait 50ms → ...

# Total time: O(n) × network_latency

2. Speculative Prefetch

The key insight: when gem A is selected, its dependencies (B, C, D) will almost certainly be needed. Rather than waiting until the resolver explicitly requests B, we spawn prefetch tasks immediately.

t=0ms   Resolve gem A
t=1ms   Spawn prefetch for B, C, D (A's deps) — don't block
t=2ms   Continue resolving A's constraints
t=48ms  Prefetch completes in background
t=50ms  Resolver needs B → cache hit ✓

In practice, speculative prefetch achieves 80-95% hit rates. The resolver rarely waits on network after the initial cold fetch. Combined with HTTP/2 connection multiplexing, we achieve near-optimal network utilization.

3. Conflict-Based Priority Inversion

Standard backtracking resolvers exhibit pathological behavior when two gems have incompatible version requirements. Consider gems A and B where A's newest versions conflict with B:

# Naive resolution (A first, B second)
Try A@3.0.0 + B@2.0.0 → conflict
Try A@2.9.0 + B@2.0.0 → conflict
Try A@2.8.0 + B@2.0.0 → conflict
... (3,847 more attempts) ...
Try A@1.0.0 + B@2.0.0 → success

Schwadler tracks conflict frequency per gem pair. After 5 consecutive conflicts between A and B, we invert their priority: B is resolved first. This technique, adapted from uv (Python's fast package manager), reduces worst-case resolution from O(n²) attempts to O(n).

4. Incremental Resolution

When updating a single gem in a 300-gem project, Bundler re-resolves the entire dependency tree. Schwadler computes the affected subgraph—only gems that transitively depend on the updated package.

# schwadl update sidekiq

1. Parse existing Gemfile.lock
2. Compute transitive dependents of sidekiq: {sidekiq-cron, activejob}
3. Affected gems: 3 of 300 (1%)
4. Lock remaining 297 gems, re-resolve only affected 3
5. Merge results → new Gemfile.lock

For typical single-gem updates, this yields 20-50x speedup over full re-resolution.

5. Zero-Copy Memory-Mapped Index

For offline resolution, Schwadler can download the entire RubyGems index (~200,000 gems) and serialize it using rkyv (zero-copy deserialization). The index is then memory-mapped at runtime.

# Traditional approach
Load file → Parse JSON/YAML → Allocate objects → ~500ms startup

# Zero-copy mmap
mmap(file) → Cast pointer → ~1ms startup

The rkyv format allows direct pointer access without parsing. Combined with mmap, the OS handles paging—only accessed portions of the index are loaded into RAM. This enables sub-millisecond index access regardless of total index size.

6. Parallelism Model

Schwadler employs a hybrid parallelism strategy:

  • Rayon for CPU-bound work (version sorting, constraint matching)
  • Tokio for I/O-bound work (HTTP requests, file operations)
  • DashMap for lock-free concurrent caching
// CPU-bound: parallel sort across all cores
versions.par_sort_by(|a, b| compare_versions(a, b));

// I/O-bound: concurrent HTTP with connection pooling
let specs = client.fetch_deps_batch(&gem_names).await;

// Lock-free cache: no mutex contention
cache.insert(gem_name, versions); // Multiple threads, no blocking

7. Architecture Overview

┌─────────────────────────────────────────────────────────┐
│                        CLI                               │
│  lock | update | install | index | cache                │
└────────────────────────┬────────────────────────────────┘
                         │
┌────────────────────────┴────────────────────────────────┐
│                     Resolver                             │
│  • BFS with constraint propagation                       │
│  • Conflict tracking + priority inversion                │
│  • Speculative prefetch                                  │
│  • Incremental mode for updates                          │
└────────────────────────┬────────────────────────────────┘
                         │
┌────────────────────────┴────────────────────────────────┐
│                   RubyGems Client                        │
│  • Compact Index protocol (same as Bundler)              │
│  • Parallel fetches (Tokio + reqwest)                    │
│  • Persistent disk cache with ETags                      │
│  • Conditional requests (If-Modified-Since)              │
└────────────────────────┬────────────────────────────────┘
                         │
┌────────────────────────┴────────────────────────────────┐
│                   Cache Layer                            │
│  • ~/.schwadler/cache/ — gem metadata                    │
│  • ~/.schwadler/index.rkyv — precomputed index           │
│  • ~/.schwadler/git/ — cloned git sources                │
└─────────────────────────────────────────────────────────┘

References

  • uv #8157 — Conflict-based backjumping in Python package resolution
  • uv #9843 — Priority inversion heuristics for version selection
  • Dart PubGrub — Version solving algorithm used in Cargo and Pub
  • rkyv — Zero-copy deserialization framework for Rust