crystal-lang / crystal

The Crystal Programming Language
https://crystal-lang.org
Apache License 2.0
19.4k stars 1.62k forks source link

Add a method to String that returns the first character #7830

Open wooster0 opened 5 years ago

wooster0 commented 5 years ago

I think a method should be added to String that returns the first character and should be a faster alternative to [0]. The strategy is to start an iteration through the string but stop at the first character. That's faster than [0]. Reason for this is for example the each_byte on ASCII-only optimization in String#each_char.

require "benchmark"

Benchmark.ips do |bm|
  bm.report "each_char" do
    "hello".each_char do |char|
      break char
    end
  end

  bm.report "[0]" do
    "hello"[0]
  end
end
each_char 394.43M (  2.54ns) (± 5.82%)  0.0B/op        fastest
      [0] 196.71M (  5.08ns) (± 5.03%)  0.0B/op   2.01× slower

The first is also faster for non-ASCII characters. This optimization is for example already used here: string.cr:4069. The method could perhaps be called first or first_char.

jhass commented 5 years ago

can we just special case String#[] with the optimization if index == 0?

I don't know, it looks like the difference boils down to the added bounds checking and support for negative indexes in byte_at, otherwise the codepaths look pretty identical (char_at is already optimized for the ascii only case).

jhass commented 5 years ago
require "benchmark"

class String
  def first_char
    c = each_char do |char|
      break char
    end
    raise IndexError.new unless c
    c
  end

  def at2(index)
    if index == 0
      first_char
    else
      at(index)
    end
  end
end

Benchmark.ips do |bm|
  bm.report "first_char" do
    "hello".first_char
  end

  bm.report "[0]" do
    "hello"[0]
  end

  bm.report "at2" do
    "hello".at2(0)
  end
end
first_char 552.12M (  1.81ns) (± 2.14%)  0.0B/op        fastest
       [0] 386.78M (  2.59ns) (± 1.67%)  0.0B/op   1.43× slower
       at2 547.62M (  1.83ns) (± 3.85%)  0.0B/op   1.01× slower
asterite commented 5 years ago

Try with a string that's not hardcoded (for example reading from ARGV). The difference is miniscule:

require "benchmark"

str = ARGV[0]

Benchmark.ips do |bm|
  bm.report "each_char" do
    str.each_char do |char|
      break char
    end
  end
  bm.report "[0]" do
    str[0]
  end
end
each_char 292.54M (  3.42ns) (± 3.93%)  0.0B/op        fastest
      [0] 273.98M (  3.65ns) (± 6.93%)  0.0B/op   1.07× slower

And if you change the order you get that the second one is fastest.

Can we stop worrying about sub-nanosecond performance differences?

Thank you!

straight-shoota commented 5 years ago

All these examples are not really worth anything as @asterite shows. That's because #char_at is super efficient for ascii-only strings because it only reads the byte at index n and converts it to Char (=> N(1)).

But with strings containing non-ascii characters, there could be a significant difference and optimizing for 0 might actually be worth it. Maybe even for other small indices, where each_char + break would be faster than the current char_at implementation.

asterite commented 5 years ago

Feel free to continue this.

HertzDevil commented 3 years ago

IMO there could be a method that converts a String to a Char if and only if it consists of exactly one character, not because of performance, but because it coincides with what sprintf "%c" could theoretically allow: (see #11022)

class String
  def chr
    # TODO: do this without computing `@length` or using `Char::Reader`
    size == 1 ? self[0] : raise ArgumentError.new
  end
end

struct Char
  def chr
    self
  end
end

Then either #chr or another name like #to_char could be used to check which arguments are supported by %c.

HertzDevil commented 10 months ago

Note that on a fresh String whose @length == 0, such as from String.build, the fastest way is to use Char::Reader regardless of its codepoint range:

require "benchmark"

def build
  String.build do |io|
    100.times { io << "abcde" }
  end
end

str = ""
ch = 'a'

Benchmark.ips do |bm|
  bm.report "control" do
    str = build
  end

  bm.report "each_char" do
    str = build
    ch = str.each_char do |char|
      break char
    end || raise IndexError.new
  end

  bm.report "[0]" do
    str = build
    ch = str[0]
  end

  bm.report "Char::Reader" do
    str = build
    ch = str.empty? ? raise IndexError.new : Char::Reader.new(str).current_char
  end
end
     control   1.69M (591.62ns) (± 3.23%)  3.02kB/op   1.00× slower
   each_char   1.20M (836.77ns) (± 2.96%)  3.02kB/op   1.41× slower
         [0]   1.20M (836.18ns) (± 2.79%)  3.02kB/op   1.41× slower
Char::Reader   1.69M (591.36ns) (± 3.96%)  3.02kB/op        fastest

This is because #each_char and #char_at both call #single_byte_optimizable? first, which calls #size, which iterates through the entire string as @length == 0. In contrast, a Char::Reader can operate without the need for String#size at all. One thing we could avoid here is the double iteration between #single_byte_optimizable? and #char_index_to_byte_index: we could cache the real @length and compute the result of #char_at in a single Char::Reader pass. It probably won't look beautiful though.

12020 merely moves this costly upfront computation to somewhere else, so it won't help in this case.

Related: #12107

straight-shoota commented 10 months ago

I suppose we should combine #single_byte_optimizable? with #size_known?. If the size is unknown, it's not optimizable.

Presumably, a method that fetches a Char at a given byte index (#char_at_byte(Int32) : Char) could be a useful addition and would work efficiently for this use case.