¿Es posible devolver cadenas congeladas y deduplicadas desde la función String # split en Ruby?
Si tenemos una cadena como esta "1,2,3,4,5" y la analizamos usando una función de división para obtener elementos individuales, Ruby asigna un nuevo objeto de cadena para cada elemento. Si procesa un archivo grande, que tiene muchos valores duplicados, por ejemplo, muchos ceros, devolver cadenas congeladas ahorrará mucho tiempo y memoria, ya que el intérprete no tendría que crear estos nuevos objetos; podría devolver un referencia a la cuerda congelada, y no sería necesario limpiar después de ellos.
Entonces, en lugar de esto: (cada objeto de cadena es ú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]
Me gustaría ver esto: (obtenemos los mismos objetos de cadena en la primera y en la segunda ejecución)
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]
Por supuesto, esto tendría que ser algún tipo de mecanismo de suscripción, que por ejemplo le permite especificar la lista de cadenas congeladas que le gustaría usar, ya que congelar cada palabra en un archivo suena como buscar problemas.
Entonces, idealmente, la interfaz sería así:
"1,2,3,4,5,6".split(',', frozen_strings: [-'1', -'2', -'3', -'4', -'5', -'6'])
¿Hay alguna forma de hacer esto en Ruby sin escribir una extensión C? ¿Quizás usar algunas bibliotecas externas como analizadores CSV?
Respuestas
Respuesta corta: No
Si su objetivo es usar cadenas congeladas para "ahorrar mucho tiempo y memoria", entonces no, eso no se puede hacer split, porque Ruby no está realmente diseñado para problemas de administración de memoria como ese.
Básicamente:
long_string.split(',') # already too late, memory allocations have happened
Pero posible
Su único recurso en Ruby puro es no crear las cadenas en primer lugar implementando una división de transmisión usted mismo. Tenga en cuenta que tendrá que evitar todos los métodos de acceso / iteración de cadenas normales como each_chare incluso []:
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 }
Tendrás que usar algo como en su each_codepointlugar:
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
De hecho, si realmente está trabajando con archivos grandes , ni siquiera querría tener toda la cadena cargada en la memoria. Querrá transmitir lecturas de archivos con algo comoIO.read
Y solo para obtener un cierre completo, suponiendo que haya implementado esto, puede agregar esa función Stringdentro de su aplicación para obtener lo que quería en primer lugar.
Prueba
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
Apéndice
Extendiéndose del PoC de OP publicado en otra respuesta :
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)
Solo para su información: la evaluación comparativa de cosas en las que una gran cantidad de memoria "se agita" siempre será bastante no determinista, ya que no tiene control sobre cuándo el recolector de basura decide actuar (y ralentiza la ejecución).
Ah, y splitpodría ser incluso más rápido con #frozen_string_literal: true, y no tengo idea de qué pasaría con --jit...
Puede obtener una cadena deduplicada congelada a través de String#-@.
O mi uso map:
str = '1,1,2,2'
str.split(',').map(&:-@).map(&:object_id)
#=> [70293234167580,
# 70293234167580,
# 70293368908400,
# 70293368908400]
o, usando el formulario de bloque para ahorrar memoria mientras se procesa una cadena 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
y llamarlo a través de:
frozen_split(str, ',').map(&:object_id)
#=> [70293234167580,
# 70293234167580,
# 70293368908400,
# 70293368908400]
Un simple to_symtambién permite reutilizar los mismos objetos. Por ejemplo:
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
Esto imprime los mismos identificadores de objeto:
[70236707757520, 70236707757480, 70236707757440, 70236707757400, 70236707757360, 70236707757320]
[70236707757520, 70236707757480, 70236707757440, 70236707757400, 70236707757360, 70236707757320]
Tenga en cuenta que el to_symmétodo, así como en la respuesta de Stefan, debería ahorrar memoria (no medí esto), pero la conversión en sí lleva algo de tiempo.
Por lo tanto, ambos métodos que reutilizan los identificadores de objetos se ejecutan más lento que el predeterminado sin conversión , consulte los resultados de la evaluación comparativa a continuación (usando ruby 2.6.6p146 (2020-03-31 revision 67876) [x86_64-darwin18] ). Tenga en cuenta que cualquier código que use estos objetos en sentido descendente podría ejecutarse más rápido, pero no estaba seguro de cuál sería ese código en su caso.
Código de evaluación comparativa:
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 de la evaluación comparativa:
###############################
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 estas operaciones son bastante rápidas. Puede ser que el cuello de botella en su caso no sea en las asignaciones de cadenas, etc., sino en la E / S: leer / escribir archivos grandes. Por lo tanto, es posible que deba optimizar algo completamente diferente, como evitar escribir archivos grandes usando tuberías, etc.
Gracias a la respuesta de Kache, redacté un PoC que resuelve mi problema. Dicho esto, este código es mucho más lento que la splitfunción 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
Aquí hay un punto de referencia de este 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)
Pude cortar esta vez y acercarme mucho a la implementación original pero con una funcionalidad muy limitada: la cadena original solo podía contener instancias de las cadenas congeladas esperadas y nada más, y las cadenas congeladas tenían que tener un solo carácter. Supongo que en algunos casos esto podría ser suficiente.