É possível retornar strings congeladas e desduplicadas da função String # split em Ruby?

Aug 28 2020

Se tivermos uma string como esta "1,2,3,4,5" e a analisarmos usando uma função de divisão para obter elementos individuais, Ruby alocará um novo objeto string para cada elemento. Se você processar um arquivo grande, que tem muitos valores duplicados, por exemplo muitos 0s, então retornar strings congeladas economizará muito tempo e memória, já que o interpretador não teria que criar esses novos objetos - ele poderia retornar um referência à string congelada - e não seria necessário limpar depois deles.

Então, em vez disso: (cada objeto string é único)

2.4.1 :007 > "1,2,3,4,5,6".split(',').map(&:object_id)
 => [70280975267840, 70280975267820, 70280975267800, 70280975267780, 70280975267760, 70280975267740]
2.4.1 :008 > "1,2,3,4,5,6".split(',').map(&:object_id)
 => [70280978671420, 70280978671400, 70280978671380, 70280978671360, 70280978671340, 70280978671320]

Eu gostaria de ver isto: (obtemos os mesmos objetos de string na primeira e na segunda execução)

2.4.1 :007 > "1,2,3,4,5,6".split(',').map(&:object_id)
 => [70280975267840, 70280975267820, 70280975267800, 70280975267780, 70280975267760, 70280975267740]
2.4.1 :008 > "1,2,3,4,5,6".split(',').map(&:object_id)
 => [70280975267840, 70280975267820, 70280975267800, 70280975267780, 70280975267760, 70280975267740]

Claro, isso precisaria ser algum tipo de mecanismo de aceitação, que por exemplo permite que você especifique a lista de strings congeladas que você gostaria de usar, já que congelar cada palavra em um arquivo soa como pedir problemas.

Então, idealmente, a interface seria assim:

"1,2,3,4,5,6".split(',', frozen_strings: [-'1', -'2', -'3', -'4', -'5', -'6'])

Existe alguma maneira de fazer isso em Ruby sem escrever uma extensão C? Talvez usando algumas bibliotecas externas como analisadores CSV?

Respostas

1 Kache Aug 28 2020 at 22:24

Resposta curta: não

Se seu objetivo é usar strings congeladas para "economizar muito tempo e memória", então não, isso não pode ser feito split, porque Ruby não foi projetado para questões de gerenciamento de memória como essa.

Basicamente:

long_string.split(',') # already too late, memory allocations have happened

Mas possivel

Seu único recurso em Ruby puro é não criar as strings implementando você mesmo uma divisão de streaming. Observe que você terá que evitar todos os métodos normais de iteração / acesso de string como each_chare até []:

str = "1,2,3,4,5"

# both will keep allocating new String objects
str.each_char.map(&:object_id)
(0...str.size).map { |i| str[i].object_id }

Você terá que usar algo como each_codepoint:

str.each_codepoint { |code| code } # does not keep allocating new objects

# so you could:
str.each_codepoint do |code|
  # implement your own parser, taking care to avoid dynamic memory allocations
end

Na verdade, se você estiver realmente trabalhando com arquivos grandes , não vai querer ter a string inteira carregada na memória. Você vai querer transmitir leituras de arquivos com algo comoIO.read

E apenas para obter o fechamento completo, supondo que você tenha implementado isso, você pode aplicar o monkeypatch a esse recurso em Stringseu aplicativo para obter o que queria em primeiro lugar.

Prova

str = "1,2,3,4,5"
puts "Run in another shell:"
puts "watch -n 1 'ps ax -o pid,rss | grep -E \"^[[:space:]]*#{$$}\"'"
GC.disable

loop do
  # doesn't keep allocating memory
  str.each_codepoint { |code| code }

  # these keep allocating memory
  # str.each_char { |c| c }
  # (0...str.size).each { |i| str[i] }
end

Termo aditivo

Extensão da PoC do OP postada em outra resposta :

NUMS = [1, 2, 3]
LONG_STR = Array.new(99_999_999) { NUMS.sample.to_s }.join(','); nil

Benchmark.bm(20) do |m|
  m.report('my_split') { my_split(LONG_STR) }

  m.report('split') { LONG_STR.split(',') }

  results = [0, nil, nil, nil, nil, 0, 0, 0]
  m.report('tally w/o alloc') do
    LONG_STR.each_codepoint do |codepoint|
      results[codepoint - 44] += 1
    end
  end
end

# Run 1              user     system      total        real
# my_split        28.670430   0.541530  29.211960 ( 30.591287)
# split           11.633294   2.578581  14.211875 ( 14.561345)
# tally w/o alloc 12.797672   0.043086  12.840758 ( 12.963547)

# Run 2              user     system      total        real
# my_split        26.526297   0.897670  27.423967 ( 28.084112)
# split           23.000878   3.849396  26.850274 ( 28.269502)
# tally w/o alloc 12.919090   0.035687  12.954777 ( 13.196385)

Apenas para sua informação: benchmarking de coisas onde muita memória "trava" sempre vai ser bem não-determinístico, já que você não tem controle de quando o coletor de lixo decide entrar em ação (e diminui a execução).

Ah, e splitpode ser ainda mais rápido com #frozen_string_literal: true, e não tenho ideia do que aconteceria com --jit...

2 Stefan Aug 28 2020 at 10:09

Você pode obter uma string congelada e desduplicada via String#-@.

Ou eu uso map:

str = '1,1,2,2'

str.split(',').map(&:-@).map(&:object_id)
#=> [70293234167580,
#    70293234167580,
#    70293368908400,
#    70293368908400]

ou, usando a forma de bloco para economizar memória enquanto processa uma string enorme: (Ruby 2.6+)

def frozen_split(str, pattern)
  return enum_for(__method__, str, pattern) unless block_given?

  str.split(pattern) { |x| yield -x }
end

e ligue para:

frozen_split(str, ',').map(&:object_id)
#=> [70293234167580,
#    70293234167580,
#    70293368908400,
#    70293368908400]
1 TimurShtatland Aug 28 2020 at 17:05

Um simples to_symtambém permite reutilizar os mesmos objetos. Por exemplo:

puts "1,2,3,4,5,6".split(',').map(&:to_sym).map(&:object_id).inspect
puts "1,2,3,4,5,6".split(',').map(&:to_sym).map(&:object_id).inspect

Isso imprime os mesmos IDs de objeto:

[70236707757520, 70236707757480, 70236707757440, 70236707757400, 70236707757360, 70236707757320]
[70236707757520, 70236707757480, 70236707757440, 70236707757400, 70236707757360, 70236707757320]

Observe que o to_symmétodo, assim como a resposta de Stefan, deve economizar memória (não fiz a medição), mas a conversão em si leva algum tempo.

Portanto, ambos os métodos que reutilizam ids de objeto são executados mais lentamente do que o padrão sem conversão , consulte os resultados do benchmarking abaixo (usando ruby 2.6.6p146 (2020-03-31 revision 67876) [x86_64-darwin18] ). Observe que qualquer código que use esses objetos downstream poderia potencialmente ser executado mais rápido, mas eu não tinha certeza de qual seria esse código no seu caso.

Código de comparação:

require 'benchmark' 

max_val = 10

[100, 1000, 10_000].each do |num_strings|
  puts "###############################"
  puts "num_strings=#{num_strings}:"
  puts "###############################"
  Benchmark.bmbm do |x|
    Kernel.srand(1234)
    x.report("default") { 10000.times { num_strings.times.map { rand(max_val) }.map(&:to_s).map(&:object_id) } }
    x.report("to_sym")  { 10000.times { num_strings.times.map { rand(max_val) }.map(&:to_s).map(&:to_sym).map(&:object_id) } }
    x.report("-@")      { 10000.times { num_strings.times.map { rand(max_val) }.map(&:to_s).map(&:-@).map(&:object_id) } }
  end
end

Resultados do benchmarking:

###############################
num_strings=100:
###############################
Rehearsal -------------------------------------------
default   0.367201   0.000213   0.367414 (  0.367492)
to_sym    0.477524   0.000333   0.477857 (  0.478012)
-@        0.489703   0.000129   0.489832 (  0.489900)
---------------------------------- total: 1.335103sec

              user     system      total        real
default   0.369533   0.000336   0.369869 (  0.370126)
to_sym    0.504686   0.000775   0.505461 (  0.508025)
-@        0.497052   0.001251   0.498303 (  0.499578)
###############################
num_strings=1000:
###############################
Rehearsal -------------------------------------------
default   3.692454   0.005807   3.698261 (  3.706056)
to_sym    4.628710   0.003317   4.632027 (  4.633834)
-@        4.844655   0.004841   4.849496 (  4.865654)
--------------------------------- total: 13.179784sec

              user     system      total        real
default   3.583169   0.002604   3.585773 (  3.587418)
to_sym    4.709409   0.004160   4.713569 (  4.717487)
-@        4.909228   0.010225   4.919453 (  4.935606)
###############################
num_strings=10000:
###############################
Rehearsal -------------------------------------------
default  37.620197   0.117046  37.737243 ( 37.867851)
to_sym   48.576790   0.156409  48.733199 ( 48.948987)
-@       49.765026   0.105483  49.870509 ( 49.998702)
-------------------------------- total: 136.340951sec

              user     system      total        real
default  36.519696   0.068643  36.588339 ( 36.654737)
to_sym   47.571235   0.157084  47.728319 ( 47.937162)
-@       49.100705   0.177943  49.278648 ( 49.434869)

NOTA:

Todas essas operações são muito rápidas. Pode ser que o gargalo no seu caso não esteja nas alocações de strings, etc, mas no I / O: leitura / gravação de arquivos grandes. Portanto, pode ser necessário otimizar algo totalmente diferente, como evitar gravar arquivos grandes usando canais, etc.

rafal Aug 31 2020 at 19:50

Graças à resposta de Kache, elaborei um PoC que resolve meu problema. Dito isso, esse código é muito mais lento do que a splitfunção original .

COMMA_CODE_POINT = ','.ord
ONE_CODE_POINT = '1'.ord
TWO_CODE_POINT = '2'.ord
THREE_CODE_POINT = '3'.ord

def my_split(string)
  result = []
  current_string = []
  string.each_codepoint do |codepoint|
    if codepoint == COMMA_CODE_POINT
      process_string_part(current_string, result)
    else
      current_string << codepoint
    end
  end

  process_string_part(current_string, result)

  result
end

def process_string_part(current_string, result)
  if current_string.size == 1
    case current_string[0]
    when ONE_CODE_POINT
      result << -'1'
    when TWO_CODE_POINT
      result << -'2'
    when THREE_CODE_POINT
      result << -'3'
    else
      result << current_string.pack('U*')
    end
    current_string.clear
  elsif current_string.size > 0
    result << current_string.pack('U*')
    current_string.clear
  end
end

Aqui está um benchmark deste código:

a = "1,2,3,3,2,1,1,2,3,3,2,1,\\N,\\N,asdasda asdasd asdad"
n = 10_000_000

Benchmark.bmbm do |x|
  x.report("split") do
    n.times do
      a.split(',')
    end
  end
  x.report("my_split") do
    n.times do
      my_split(a)
    end
  end
end
            user     system      total        real
split    21.926568   0.000002  21.926570 ( 21.927100)
my_split 71.138833   0.000000  71.138833 ( 71.140378)

Consegui cortar esse tempo e chegar muito perto da implementação original, mas com funcionalidade muito limitada - a string original só poderia conter instâncias das strings congeladas esperadas e nada mais, e as strings congeladas precisavam ter apenas um caractere. Acho que em alguns casos isso pode ser o suficiente.