Project

General

Profile

Bug #22313

Updated by make_now_just (Hiroya Fujinami) 16 days ago

Compiling one large method whose body contains many unconditional jumps takes time quadratic in the size of the method. 

 ## Reproduction 

 ```ruby 
 def build(units) 
   src = +"def f(x)\n" 
   units.times do |i| 
     src << "    while x < #{i}\n      x = x + 1\n      next if x == #{i}\n      break if x > #{i}\n    end\n    return x if x == #{i}\n" 
   end 
   src << "    x\nend\n" 
 end 

 [5_000, 10_000, 20_000].each do |units| 
   src = build(units) 
   t = Process.clock_gettime(Process::CLOCK_MONOTONIC) 
   RubyVM::InstructionSequence.compile(src) 
   printf("%6d units: %5.2f s\n", units, Process.clock_gettime(Process::CLOCK_MONOTONIC) - t) 
 end 
 ``` 

 On `ruby 4.1.0dev (2026-09-14T06:17:27Z master d973aef325) +PRISM [arm64-darwin25]` (Apple M1 Pro): 

 ``` 
   5000 units:    0.25 s 
  10000 units:    1.08 s 
  20000 units:    4.38 s 
 ``` 

 Doubling the method's size quadruples the compile time. 
 Ruby 4.0.4 behaves the same. 

 ## Cause 

 `remove_unreachable_chunk` in `compile.c` allocates a counter array sized by the method's label count and clears it on every call: 

 ```c 
     int *unref_counts = 0, nlabels = ISEQ_COMPILE_DATA(iseq)->label_no; 

     if (!i) return 0; 
     unref_counts = ALLOCA_N(int, nlabels); 
     MEMZERO(unref_counts, int, nlabels); 
 ``` 

 `iseq_peephole_optimize` calls it for every unconditional `jump` jump and `leave` leave it visits, and both the number of those calls and label_no grow linearly with the method, so `MEMZERO` the MEMZERO alone costs O(jumps x labels) per method. 
 A profile of the reproduction spends most of its compile time in `__bzero` under `iseq_peephole_optimize` -> `remove_unreachable_chunk`. 
 As a side note, `ALLOCA_N` is also unbounded: a method with tens of thousands of labels puts hundreds of kilobytes on the C stack per call.

Back