Repository navigation
JRuby performance regression for CSV.parse_line #361
Description
When run with JRuby, CSV.parse_line (and any CSV#each iteration) has a noticeable performance regression because CSV#each pulls rows one at a time through Enumerator#next.
CSV.parse_line is defined as new(line, **options).each.first
Lines 1898 to 1899 in b36c160
| def parse_line(line, **options) | |
| new(line, **options).each.first |
CSV#each iterates by repeatedly calling parser_enumerator.next
Lines 2689 to 2697 in b36c160
| def each(&block) | |
| return to_enum(__method__) unless block_given? | |
| begin | |
| while true | |
| yield(parser_enumerator.next) | |
| end | |
| rescue StopIteration | |
| end | |
| end |
def each(&block)
return to_enum(__method__) unless block_given?
begin
while true
yield(parser_enumerator.next)
end
rescue StopIteration
end
endparser_enumerator is parser.parse (an Enumerator
Lines 2974 to 2975 in b36c160
| def parser_enumerator | |
| @parser_enumerator ||= parser.parse |
Per the Ruby documentation, external iteration with next "differs significantly from internal iteration due to using a Fiber" and "the Fiber adds some overhead compared to internal enumeration" https://docs.ruby-lang.org/en/3.4/Enumerator.html
Change that introduced it
CSV#each previously called parser_enumerator.each(&block) (internal iteration, no Fiber). It was changed to the Enumerator#next loop in commit acc05116c55579b78925dd27024969b9daf69135 ("All Enumerable based methods consume the same lines with other methods", acc0511), to fix GH-260 (#260). That commit message itself notes: "This may have a performance penalty. We should benchmark this." It was first released in v3.2.6
Reproducer
require "csv"
require "benchmark"
ROW = (1..30).map { |i| "field_#{i}" }.join(",")
N = 50_000
def rate(n, s) = (n / s).round
N.times { CSV.parse_line(ROW) }
parse_line = rate(N, Benchmark.realtime { N.times { CSV.parse_line(ROW) } })
en = rate(N, Benchmark.realtime { N.times { Enumerator.new { |y| y << 1 }.next } })
eb = rate(N, Benchmark.realtime { N.times { Enumerator.new { |y| y << 1 }.each { |v| break v } } })
engine = defined?(JRUBY_VERSION) ? "JRuby #{JRUBY_VERSION}" : RUBY_ENGINE
puts "#{engine} Ruby #{RUBY_VERSION} csv #{CSV::VERSION}"
puts "CSV.parse_line : #{parse_line} calls/sec"
puts "bare Enumerator#next : #{en} calls/sec"
puts "bare Enumerator#each+break : #{eb} calls/sec"Output, JRuby 10.0.5.0 (via jruby-complete-10.0.5.0.jar, csv 3.3.5):
JRuby 10.0.5.0 Ruby 3.4.5 csv 3.3.5
CSV.parse_line : 12270 calls/sec
bare Enumerator#next : 27411 calls/sec
bare Enumerator#each+break : 447421 calls/sec
Output, MRI CRuby 3.4.10 (csv 3.3.5), for contrast:
ruby Ruby 3.4.10 csv 3.3.5
CSV.parse_line : 46946 calls/sec
bare Enumerator#next : 291157 calls/sec
bare Enumerator#each+break : 1159496 calls/sec
Activity
Pin csv to avoid jrubuy performance regression
Pin csv to avoid jrubuy performance regression (#94)
[-]Jruby performance regression for `CSV.parse_line`[/-][+]JRuby performance regression for `CSV.parse_line`[/+]Don't use enumerator in CSV (#363)
Introduce a raw Fiber path for immediately-resumed cases
headius commentedon Jul 27, 2026
I was happy to see this got reported and resolved over the weekend. I wanted to do some additional investigation on the JRuby side.
First, analyzing the benchmark provided by @donoghuc...
Enumerator#next benchmark
The
Enumerator#nextbenchmark here is mostly measuring the cost of spinning up a new Fiber, since the Enumerator is created, "next"ed, and immediately abandoned. This is essentially a worst case scenario for JRuby's Fiber implementation, since it will force the entire backend stack (RubyThread, ThreadContext, and JDK VirtualThread) to be constructed just to server a single result. This manifested in a JDK Mission Control profile by showing excessive allocation of objects throughout that stack.A prototype patch I have in hand delays some of the startup of the Fiber until the first request is submitted (i.e. until the first
resumecall) which improves thenext` benchmark about 30%. This is better but still about 1/3 the performance of CRuby (which has much lighter-weight Fiber constructs currently).Given the demands of JDK VirtualThread (a much more complex creature than CRuby's fibers/continuations), this case will be very hard to improve further.
The benchmark is also complicated by the fact that these enumerators are left "dangling", so there's a periodic GC pause as orphaned fibers and virtual threads get cleaned up.
So again, it's a worst-case scenario. I will see about cleaning up the LLM-generated patch I have for lazily starting the VirtualThread, but this pattern should generally be discouraged (and it's obviously still very heavy on CRuby too).
Enumerator#each benchmark
The "each" benchmark still creates a new Enumerator each time, but at least does not trigger a whole new fiber stack to get created. It performs about 20x better on my machine with JRuby 10.1 master and JDK 26. The gap with CRuby is narrowed but not eliminated (CRuby is about 5x faster for the
eachversion than for thenextversion).Profiling this benchmark shows a lot of overhead in Enumerator creation that would affect both benchmarks, so I'll be exploring how to reduce that overhead. A significant part of the overhead in the
eachbench is due to Enumerator eagerly setting up state used only when anextfiber is active; that can clearly be deferred, since many (most?) Enumerators won't be used tonext.I'm looking into options for reducing the creation cost of an Enumerator that is never used for #next but this is again a fairly specific case. That overhead may also be small compared to the cost of creating the new block, yielding to it, and breaking out with the first value.
CSV change
I think it's fair to say this performs better on both CRuby and JRuby, so I don't see it as a JRuby-specific fix. I like the change, though, since it is both simpler and less overhead.
It still seems like the following code is unnecessarily heavy:
To me this seems like a clear case for a "parse1" method similar to
String#unpack1that avoids all of the overhead of each + block + non-local return. I have not dug into the codebase to see if that pattern fits, but it would be a further elimination of iterator-like behavior when only a single value is desired.