2013-09-12

Ruby の CSV#<< が Encoding::compatible? を呼んでいるため遅い

Ruby 1.9.3 の CSV で行数の多い CSV を作っていたところ,長い時間がかかった.

ruby-prof で測定したところ,#<< から呼ばれる Encoding::compatible? が大部分を占めていた.

$ cat large-csv.rb 
require 'csv'
require 'stringio'
file = StringIO.new
csv = CSV.new(file)
100_000.times do
  csv << Array.new(10,'a'*20)
end
p file.length
$ ruby-prof -f before.html -p call_stack large-csv.rb
21000000
ruby-prof result

この部分のコードを以下に引用する.

https://github.com/ruby/ruby/blob/v1_9_3_392/lib/csv.rb#L1730-1732
    if @io.is_a?(StringIO)             and
       output.encoding != raw_encoding and
       (compatible_encoding = Encoding.compatible?(@io.string, output))

最初の 2 条件が満たされると,Encoding.compatible? が呼ばれてしまう.

第 1 条件:以下引用するコードによると,::new の第一引数に String あるいは StringIO を与えると @ioStringIO となる.

https://github.com/ruby/ruby/blob/v1_9_3_392/lib/csv.rb#L1563-1568
  def initialize(data, options = Hash.new)
    # build the options for this read/write
    options = DEFAULT_OPTIONS.merge(options)

    # create the IO object we will read from
    @io       = data.is_a?(String) ? StringIO.new(data) : data

第 2 条件左辺:以下引用するコードによると,::new の第二引数の :encoding オプションに与えた値と #<< に与えた Array 中の String#encoding によって output.encoding は決定される.

https://github.com/ruby/ruby/blob/v1_9_3_392/lib/csv.rb#L1729
    output = row.map(&@quote).join(@col_sep) + @row_sep  # quote and separate
https://github.com/ruby/ruby/blob/v1_9_3_392/lib/csv.rb#L2090-2116
    @force_quotes   = options.delete(:force_quotes)
    do_quote        = lambda do |field|
      field         = String(field)
      encoded_quote = @quote_char.encode(field.encoding)
      encoded_quote                                +
      field.gsub(encoded_quote, encoded_quote * 2) +
      encoded_quote
    end
    quotable_chars = encode_str("\r\n", @col_sep, @quote_char)
    @quote         = if @force_quotes
      do_quote
    else
      lambda do |field|
        if field.nil?  # represent +nil+ fields as empty unquoted fields
          ""
        else
          field = String(field)  # Stringify fields
          # represent empty fields as empty quoted fields
          if field.empty? or
             field.count(quotable_chars).nonzero?
            do_quote.call(field)
          else
            field  # unquoted field
          end
        end
      end
    end
https://github.com/ruby/ruby/blob/v1_9_3_392/lib/csv.rb#L2023-2025
    @col_sep    = options.delete(:col_sep).to_s.encode(@encoding)
    @row_sep    = options.delete(:row_sep)  # encode after resolving :auto
    @quote_char = options.delete(:quote_char).to_s.encode(@encoding)

第 2 条件右辺:いっぽう,以下引用するコードによると,raw_encoding は,@io のそれとなる.

https://github.com/ruby/ruby/blob/v1_9_3_392/lib/csv.rb#L2326-2328
  def raw_encoding(default = Encoding::ASCII_8BIT)
    if @io.respond_to? :internal_encoding
      @io.internal_encoding || @io.external_encoding

StringIO#set_encoding でこれを指定することができる.

以上に従って,次のようにして CSV を作成した.

$ cat large-csv-modified.rb
require 'csv'
require 'stringio'
file = StringIO.new
file.set_encoding(Encoding::UTF_8)
csv = CSV.new(file, {:encoding => Encoding::UTF_8})
100_000.times do
  csv << Array.new(10,('a'*20).encode(Encoding::UTF_8))
end
p file.length
$ ruby-prof -f after.html -p call_stack large-csv-modified.rb
21000000
ruby-prof result

時間のかかっていた Encoding::compatible? が呼ばれなくなった.