Skip to content

Compilation Error: Complex Classes Exceed Branch Quota During Discovery #565

Description

@adam-jahraus

Compilation Error: Complex Classes Exceed Branch Quota During Discovery

First, let me just say thank you for creating Ziggy-Pydust, it's a fantastic package! The overall summary of this issue is "many of my Python classes work so well that the ones which don't seem to be able to be implemented in Ziggy-Pydust make me sad."

Environment

  • Pydust Version: 0.26.0
  • Zig Version: 0.15.2
  • Platform: Windows (x86_64)
  • Python Version: 3.11.9
  • Build Type: Both Debug and ReleaseFast

Summary

When implementing moderately complex Python classes (10+ fields, 4+ methods) using pydust, compilation fails with "evaluation exceeded 10000 backwards branches" during the compile-time discovery phase. This occurs even when following best practices (struct-based parameters, simple init signatures).

The issue appears to be in pydust's discovery.zig module, which recursively analyzes all type information at compile time, causing combinatorial explosion with complex class definitions.

Expected Behavior

Classes with 10-15 fields and 4-5 methods should compile successfully, as they represent common real-world use cases for Python extension modules.

Actual Behavior

Compilation fails during the discovery phase with:

vendor\pydust\pydust\src\discovery.zig:39:20: error: evaluation exceeded 10000 backwards branches
            inline for (info.fields) |f| {
            ~~~~~~~^~~
vendor\pydust\pydust\src\discovery.zig:39:20: note: use @setEvalBranchQuota() to raise the branch limit from 10000

This error occurs even when:

  1. Using struct-based parameters for __init__ (only 2 total parameters: self + single struct)
  2. Keeping individual methods simple
  3. Following the patterns used in simpler working examples

Minimal Reproduction

Classes That Work ✅

Simple classes with 2-7 fields and 2-3 methods compile successfully:

pub const SimpleHeader = py.class(struct {
    const Self = @This();
    
    // 7 fields - typical message header
    field_a: py.PyBytes,
    field_b: u8,
    field_c: u8,
    field_d: u32,
    field_e: u16,
    field_f: u16,
    field_g: u32,
    
    pub fn __init__(self: *Self, args: struct { 
        field_a: py.PyBytes,
        field_b: u8,
        field_c: u8,
        field_d: u32,
        field_e: u16,
        field_f: u16,
        field_g: u32,
    }) !void {
        self.field_a = args.field_a;
        self.field_b = args.field_b;
        self.field_c = args.field_c;
        self.field_d = args.field_d;
        self.field_e = args.field_e;
        self.field_f = args.field_f;
        self.field_g = args.field_g;
    }
    
    pub fn from_bytes(args: struct { raw_bytes: py.PyBytes }) !*Self {
        const bytes = try args.raw_bytes.asSlice();
        // Parse bytes into fields...
        return try py.init(root, Self, .{
            .field_a = try py.PyBytes.create(bytes[0..2]),
            .field_b = bytes[2],
            .field_c = bytes[3],
            // ... etc
        });
    }
    
    pub fn to_bytes(self: *Self) !py.PyBytes {
        var buffer: [16]u8 = undefined;
        // Serialize fields to buffer...
        return try py.PyBytes.create(&buffer);
    }
});

Classes That Fail ❌

Classes with 10+ fields and 4+ methods fail compilation:

pub const ComplexDataBlock = py.class(struct {
    const Self = @This();
    
    // 10 fields - typical data structure with multiple related values
    id: u16,
    flag_x: bool,
    value_x: i32,
    flag_y: bool,
    value_y: i32,
    flag_z: bool,
    value_z: i32,
    rate_x: i32,
    rate_y: i32,
    rate_z: i32,
    
    // Standard __init__ with struct parameter (following best practices)
    pub fn __init__(self: *Self, args: struct {
        id: u16,
        flag_x: bool,
        value_x: i32,
        flag_y: bool,
        value_y: i32,
        flag_z: bool,
        value_z: i32,
        rate_x: i32,
        rate_y: i32,
        rate_z: i32,
    }) !void {
        self.id = args.id;
        self.flag_x = args.flag_x;
        self.value_x = args.value_x;
        self.flag_y = args.flag_y;
        self.value_y = args.value_y;
        self.flag_z = args.flag_z;
        self.value_z = args.value_z;
        self.rate_x = args.rate_x;
        self.rate_y = args.rate_y;
        self.rate_z = args.rate_z;
    }
    
    // Factory method following py.init() pattern
    pub fn from_bytes(args: struct { 
        raw_bytes: py.PyBytes, 
        config: py.PyString 
    }) !*Self {
        const bytes_slice = try args.raw_bytes.asSlice();
        const config_slice = try args.config.asSlice();
        
        // Parse variable-length binary data based on config
        // Extract 10 fields using bit manipulation (~20 lines)
        
        return try py.init(root, Self, .{
            .id = id,
            .flag_x = flag_x,
            .value_x = value_x,
            // ... 7 more field assignments
        });
    }
    
    // Serialization method
    pub fn to_bytes(self: *Self, args: struct { 
        config: py.PyString 
    }) !py.PyBytes {
        // Serialize 10 fields to variable-length format
        // Based on config parameter (~30 lines)
        return try py.PyBytes.create(&buffer);
    }
    
    // String representation
    pub fn __repr__(self: *const Self) !py.PyString {
        // Format string with all 10 fields (~5 lines)
    }
});

Error when exporting this class:

vendor\pydust\pydust\src\discovery.zig:39:20: error: evaluation exceeded 10000 backwards branches
            inline for (info.fields) |f| {
            ~~~~~~~^~~

Analysis

Root Cause

The issue is in pydust's compile-time discovery system, which recursively analyzes:

  1. All struct fields
  2. All methods and their parameters
  3. All nested types within parameters (e.g., the struct in args: struct { ... })
  4. All declarations within the class

For each field/method, the analysis:

  • Calls countDefinitions() recursively
  • Calls getIdentifiers() for each declaration
  • Iterates through all nested type information

With 10 fields + 4 methods + nested struct parameters:

  • Each method's struct parameter adds ~10 more "fields" to analyze
  • Total declarations: ~50-70 items
  • Recursive depth: 3-4 levels
  • Approximate branches: 10,000+ (exceeds quota)

Why Simple Classes Work

Classes with 3-5 fields and 2-3 methods stay under the branch quota because:

  • Fewer total declarations (~15-20)
  • Shallower recursion depth
  • Smaller parameter structs

Attempted Workarounds

We tried several approaches, all unsuccessful:

  1. ✗ Struct-based parameters: Still exceeds quota (this is already best practice)
  2. ✗ Splitting into multiple files: Doesn't help - analysis happens per-class
  3. ✗ Simplifying methods: Even with minimal logic, declaration count is the issue
  4. ✗ Removing helper functions: Marginal improvement, still exceeds quota
  5. ✗ Using @setEvalBranchQuota(): Not available to user code calling pydust

Impact

This limitation significantly restricts pydust's usefulness for real-world applications:

What We Can't Implement

  1. Data structures with many fields: Common in binary protocols, network packets, file formats
  2. Classes with rich APIs: Multiple conversion methods (from_bytes, to_bytes, from_json, etc.)
  3. Domain objects: Business logic objects often have 10-20 fields

What We Can Implement

  1. Simple message headers: 3-7 fields (works great, 30-150x speedup achieved)
  2. Wrapper classes: Thin wrappers around single values
  3. Stateless utility functions: Functions without class context

Comparison with Working Classes

We successfully implemented 8 classes that work perfectly:

Class Fields Methods Status
MessageHeaderA 7 3 ✅ Works (30-66x speedup)
MessageHeaderB 4 3 ✅ Works (32-52x speedup)
MessageHeaderC 6 3 ✅ Works (32-68x speedup)
StatusBlock 3 3 ✅ Works (21-34x speedup)
DescriptorBlock 7 3 ✅ Works (43-85x speedup)
TimestampBlock 3 3 ✅ Works (18-34x speedup)
ComplexDataBlock 10 4 ❌ Fails (branch quota exceeded)
ConfigBlock 2 4 ❌ Fails (branch quota exceeded)

Note: Even ConfigBlock with only 2 fields fails when it has 4 methods, suggesting the method count contributes significantly to branch usage.

Proposed Solutions

Option 1: Increase Branch Quota in Discovery Module

Add @setEvalBranchQuota() in discovery.zig:

// In discovery.zig, before expensive loops
pub fn getAllIdentifiers(definition: type) []const Identifier {
    @setEvalBranchQuota(50000); // Increase from default 10000
    // ... existing code ...
}

Pros: Simple fix, allows more complex classes
Cons: May hide deeper performance issues

Option 2: Optimize Discovery Algorithm

Reduce redundant analysis:

  • Cache type information that's analyzed multiple times
  • Skip analyzing standard library types
  • Implement iterative instead of recursive approach where possible

Pros: More sustainable, better performance overall
Cons: Requires significant refactoring

Option 3: Lazy Discovery

Defer some type analysis until actually needed:

  • Only analyze methods that are actually called from Python
  • Use lazy evaluation for nested types

Pros: Scales better with complex classes
Cons: More complex implementation, potential runtime overhead

Option 4: Exclude Internal Details

Allow marking fields/methods to skip during discovery:

// Hypothetical attribute to skip discovery
pub fn internalHelper() void comptime_exclude {}

Pros: Gives users control over complexity
Cons: Requires new language features or conventions

Request

Could you please:

  1. Increase the branch quota in discovery.zig as a short-term fix (Option 1)
  2. Consider optimizing the discovery algorithm for long-term scalability (Option 2)
  3. Document the complexity limits so users understand what's feasible

Workaround

For now, we're keeping complex classes in Python and only using pydust for simple message headers (which still provides excellent 30-150x speedup). However, this limits pydust's applicability to a subset of potential use cases.

Additional Context

  • Use case: Binary protocol parser for network communications
  • Successfully built 8 simpler classes with great results (30-150x speedup)
  • Blocked on implementing 7 more complex data structures
  • All code follows pydust best practices (struct-based parameters, py.init pattern)
  • Testing with Zig 0.15.2 and Pydust 0.26.0

Related Issues

  • Similar to any existing issues about compile-time evaluation limits
  • Related to general compile-time evaluation performance

This Issue was primarily written by Claude Sonnet 4.5, and reviewed and edited by myself.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions