Surprising non-performance of String#split

So I was hacking together a tool to chew through a CSV file and was considering performance as I’ll have to process something in the vicinity of 11 million rows. So I was playing around with a simple script to figure out how unique a column was. So, a nice-looking Crystal version:

Processed 1038785 rows in 2.5482900969999998 sec, 407640.00975513743 per sec

Then I let Gemini port it to PHP:

Processed 1038785 rows in 0.42527914047241 sec, 2442595.7004289 per sec

What the…? Crystal massively outpaced by an interpreted language?

Tried recompiling with --release --production:

Processed 1038785 rows in 0.68309683 sec, 1520699.488533712 per sec

Better, but still pretty pathetic next to PHP. Trying commenting out things, I found that the major time sink was cols = line.split(SEPARATOR). I know, it allocates a new array of strings, and there’s more effective ways to do it when optimizing, but it’s up against $cols = explode(self::SEPARATOR, rtrim($line, "\r\n")); which does the same thing.

Is String#split really that slow? (I tried splitting both with a String and a Char separator).

This is strange because, on LangArena benchmark Etc::Words is essentially only @text.split(' ') { |w| frequencies.update(w, &.+(1)) }, as you can see Crystal fastest in this test among 22 languages.

Interpreted vs. compiled isn’t really that relevant in this case. We’re talking about the performance of a built-in function of PHP which is implemented in C. And it’s probably implemented quite efficiently. Perhaps more efficiently than String#split.

It seems String#split misses a couple of optimizations. For example, when the separator is a single byte (or single-byte optimizable), which seems to apply to your use case?

There could also be some differences in the runtime, e.g. the efficiency of allocating strings. I presume there’s probably not much difference there, though. Or maybe PHP is at a bit of a disadvantage due to the interpreter overhead.

Which would imply that PHP is faster than a number of compiled languages. Maybe the block version is faster?

You’re right, if I comment out the split and use hardcoded strings as a placeholder, things speeds up considerably.

# crystal run
Processed 1038786 rows in 0.323526817 sec, 3210818.842259991 per sec
# --release --production
Processed 1038786 rows in 0.069133719 sec, 15025750.314401574 per sec
# PHP
Processed 1038786 rows in 0.12817907333374 sec, 8104177.7958194 per sec

But PHP is still more than twice as fast than un-optimized Crystal.

It would seem that PHP is pretty well optimized for this case, tried having Gemini do a NodeJS version of the original script:

Processed 1038785 rows in 1.5042152469999999 sec, 690582.6822801777 per sec

Half the speed of PHP, but still faster than un-optimized Crystal.

Yeah, it’s just splitting on commas. The PHP version seems awfully simple, but simple often helps hardware optimization.

Can you provide a simple crystal file benchmarking the relevant fragments? Everyone loves an optimization thing

# TODO: Write documentation for `Uniq`
module Uniq
  VERSION = "0.1.0"
  SEPARATOR = ','
  MAX_ITERATIONS = 1_500_000
  OUTPUT_EVERY = 100_000

  def self.main(args : Array(String))
    file = args[0]?

    abort("please provide a file") unless file

    isbns = Set(String).new

    processed : Int32 = 0
    start_time = Time.instant
    File.open(file, "r").each_line do |line|
      break if processed >= MAX_ITERATIONS
      cols = line.split(SEPARATOR)

      next if cols[0] == "ISBN"
      processed += 1

      isbns << cols[0]

      put_status(start_time, processed) if processed % OUTPUT_EVERY == 0
    end
    puts "Done"
    puts ""

    puts "#{processed} ISBN numbers, #{isbns.size} unique"

    put_status(start_time, processed)
  end

  def self.put_status(start_time : Time::Instant, processed : Int32)
    end_time = Time.instant
    seconds = end_time.duration_since(start_time).total_seconds
    rate = processed / seconds
    puts "Processed #{processed} rows in #{seconds} sec, #{rate} per sec"
  end

end

Uniq.main(ARGV)

The CSV has 7 columns, but the script only cares about the first. I can’t share the CSV I’ve used, the 165MiB size being one factor, but it shouldn’t be to hard to generate something.

The gist of the PHP implementation is the zend_memnstr function which performs the actual substring search. There is:

  • memchr for single-byte separators (there is no distinct code path for character separators versus strings)
  • memchr + memcmp for subject strings up to 1023 bytes and separator strings up to 8 bytes
  • Sunday algorithm for longer subjects or separators

Things would be more complicated on our end if either the subject or the separator contains invalid UTF-8 byte sequences.

Have you tried something like scan(/[^,]+/) or scan(/(.+?)(,|\z)/) as well?

No wonder that memchr is faster than iterating through the entire string with Char::Reader.

Even worse (without --release, comparable to the very first

Processed 1038785 rows in 4.328300535 sec, 239998.3530718483 per sec
Processed 1038785 rows in 4.887059216 sec, 212558.30021438398 per sec

So, theoretically speaking, what would one do if one had the need to really paint go-faster-stripes on an app? Just call memchr?

There’s no useful performance data without --release, unfortunately. Compile-time optimizations are where the majority of a program’s performance comes from. The PHP interpreter you’re using almost certainly has them enabled.

Well, even with --release the ratios between the different implementations is pretty much the same.

Benchmarking is tricky.

An unoptimized Crystal program can’t be faster than an interpreted language when the latter spends most of its time in optimized compiled code.

Are you comparing String#split against mb_str_split() that are both Unicode aware, or to str_split() that expects ASCII? The latter can be much more optimized.

IIUC and you are extracting the 1st field which is a ISBN then you can use ascii even if the rest is not ascii :-)

The comparison is with explode, not str_split. It seems explode is also Unicode aware. But it really doesn’t matter because the separator is a single ASCII character and it can be implemented with memchr (which the PHP engine does, String#split does not).

memchr typically utilizes SIMD instructions so it’s much more efficient than iterating each byte. We could easily do that in String#split, too.

I implemented a byte-based loop for separators that are ASCII characters. It seems promising except when the splitted parts are very short (M < 10). I also tried to use the single-pass Rabin-Karp algorithm from this related PR for multibyte separators, and the results are slower than the existing naive loop for some reason.

However:

If this is the case then #partition or even #index is enough, and the Crystal code will probably be on par with a PHP equivalent.

I tried something similar the other night after seeing this thread and had the same results. Byte-by-byte iteration, precise allocations (no reallocs), and somehow it was slower than stdlib String#split for the inputs I tried. That was really surprising.

One thing that was a little over 3x as fast in the 1000x1000 case, though, was combining the precise allocations from that idea with a SWAR implementation based on ideas I got from this blog post along with a bitmask to find occurrences of the separator.

Benchmark results
1x1
stdlib  50.27M ( 19.89ns) (± 1.81%)  80.0B/op   1.41× slower
  SWAR  70.86M ( 14.11ns) (± 1.41%)  48.0B/op        fastest

1x3
stdlib  44.33M ( 22.56ns) (± 1.12%)  96.0B/op   1.50× slower
  SWAR  66.28M ( 15.09ns) (± 1.27%)  48.0B/op        fastest

1x10
stdlib  37.98M ( 26.33ns) (± 0.89%)  96.0B/op   1.65× slower
  SWAR  62.73M ( 15.94ns) (± 1.12%)  48.0B/op        fastest

1x30
stdlib  25.75M ( 38.84ns) (± 0.83%)   112B/op   2.02× slower
  SWAR  52.01M ( 19.23ns) (± 1.58%)  48.0B/op        fastest

1x100
stdlib  12.04M ( 83.02ns) (± 2.36%)   192B/op   3.19× slower
  SWAR  38.37M ( 26.06ns) (± 0.83%)  48.0B/op        fastest

1x300
stdlib   5.15M (194.32ns) (± 2.34%)   384B/op   4.39× slower
  SWAR  22.58M ( 44.30ns) (± 0.45%)  48.0B/op        fastest

1x1000
stdlib   1.70M (589.11ns) (± 3.18%)  1.06kB/op   5.06× slower
  SWAR   8.58M (116.52ns) (± 0.88%)   48.0B/op        fastest

3x1
stdlib  29.95M ( 33.39ns) (± 0.71%)  112B/op        fastest
  SWAR  26.41M ( 37.86ns) (± 0.88%)  112B/op   1.13× slower

3x3
stdlib  24.10M ( 41.50ns) (± 0.80%)  160B/op        fastest
  SWAR  23.13M ( 43.22ns) (± 0.53%)  160B/op   1.04× slower

3x10
stdlib  18.69M ( 53.51ns) (± 1.83%)  160B/op   1.20× slower
  SWAR  22.49M ( 44.47ns) (± 0.76%)  160B/op        fastest

3x30
stdlib  11.06M ( 90.44ns) (± 0.77%)  208B/op   1.54× slower
  SWAR  17.00M ( 58.82ns) (± 0.78%)  208B/op        fastest

3x100
stdlib   4.35M (230.03ns) (± 1.58%)  448B/op   2.60× slower
  SWAR  11.29M ( 88.56ns) (± 0.60%)  448B/op        fastest

3x300
stdlib   1.78M (560.87ns) (± 1.89%)  1.0kB/op   2.90× slower
  SWAR   5.16M (193.66ns) (± 0.83%)  1.0kB/op        fastest

3x1000
stdlib 559.52k (  1.79µs) (± 2.99%)  3.07kB/op   2.94× slower
  SWAR   1.65M (607.06ns) (± 1.13%)  3.07kB/op        fastest

10x1
stdlib   9.22M (108.46ns) (± 0.74%)  400B/op   1.14× slower
  SWAR  10.55M ( 94.77ns) (± 0.47%)  288B/op        fastest

10x3
stdlib   7.51M (133.23ns) (± 0.70%)  560B/op   1.11× slower
  SWAR   8.30M (120.55ns) (± 0.72%)  448B/op        fastest

10x10
stdlib   5.46M (183.16ns) (± 1.39%)  560B/op   1.47× slower
  SWAR   8.00M (124.97ns) (± 0.84%)  448B/op        fastest

10x30
stdlib   3.13M (319.33ns) (± 1.12%)  720B/op   1.90× slower
  SWAR   5.96M (167.89ns) (± 1.03%)  608B/op        fastest

10x100
stdlib   1.26M (792.86ns) (± 2.40%)  1.48kB/op   2.83× slower
  SWAR   3.56M (280.64ns) (± 0.59%)  1.38kB/op        fastest

10x300
stdlib 511.23k (  1.96µs) (± 2.50%)  3.36kB/op   2.96× slower
  SWAR   1.52M (659.85ns) (± 1.20%)  3.25kB/op        fastest

10x1000
stdlib 166.05k (  6.02µs) (± 2.81%)  10.3kB/op   2.97× slower
  SWAR 493.94k (  2.02µs) (± 1.26%)  10.2kB/op        fastest

30x1
stdlib   3.33M (299.95ns) (± 1.19%)  1.35kB/op   1.14× slower
  SWAR   3.80M (262.93ns) (± 0.97%)    768B/op        fastest

30x3
stdlib   2.60M (384.11ns) (± 1.06%)  1.82kB/op   1.19× slower
  SWAR   3.09M (323.62ns) (± 1.09%)  1.22kB/op        fastest

30x10
stdlib   1.89M (529.27ns) (± 1.37%)  1.82kB/op   1.53× slower
  SWAR   2.90M (345.21ns) (± 0.78%)  1.22kB/op        fastest

30x30
stdlib   1.11M (898.79ns) (± 0.81%)  2.29kB/op   1.88× slower
  SWAR   2.09M (477.42ns) (± 0.72%)  1.69kB/op        fastest

30x100
stdlib 431.38k (  2.32µs) (± 2.40%)  4.63kB/op   2.78× slower
  SWAR   1.20M (834.17ns) (± 0.70%)  4.03kB/op        fastest

30x300
stdlib 170.82k (  5.85µs) (± 3.45%)  10.3kB/op   2.95× slower
  SWAR 503.63k (  1.99µs) (± 0.53%)  9.66kB/op        fastest

30x1000
stdlib  57.45k ( 17.41µs) (± 3.59%)  30.9kB/op   3.08× slower
  SWAR 176.96k (  5.65µs) (± 0.48%)  30.3kB/op        fastest

100x1
stdlib   1.16M (861.90ns) (± 0.58%)  5.21kB/op   1.06× slower
  SWAR   1.22M (816.49ns) (± 0.80%)   2.6kB/op        fastest

100x3
stdlib 902.04k (  1.11µs) (± 0.66%)  6.78kB/op   1.12× slower
  SWAR   1.01M (991.19ns) (± 1.09%)  4.16kB/op        fastest

100x10
stdlib 649.83k (  1.54µs) (± 1.33%)  6.78kB/op   1.39× slower
  SWAR 903.73k (  1.11µs) (± 0.78%)  4.16kB/op        fastest

100x30
stdlib 357.96k (  2.79µs) (± 1.08%)  8.34kB/op   1.82× slower
  SWAR 651.39k (  1.54µs) (± 1.05%)  5.72kB/op        fastest

100x100
stdlib 135.92k (  7.36µs) (± 2.86%)  16.1kB/op   2.85× slower
  SWAR 386.93k (  2.58µs) (± 0.77%)  13.6kB/op        fastest

100x300
stdlib  52.60k ( 19.01µs) (± 3.30%)  34.9kB/op   3.08× slower
  SWAR 162.17k (  6.17µs) (± 0.87%)  32.3kB/op        fastest

100x1000
stdlib  17.52k ( 57.07µs) (± 3.57%)  104kB/op   3.11× slower
  SWAR  54.47k ( 18.36µs) (± 0.92%)  101kB/op        fastest

300x1
stdlib 456.22k (  2.19µs) (± 0.49%)  11.3kB/op        fastest
  SWAR 415.00k (  2.41µs) (± 0.40%)  7.08kB/op   1.10× slower

300x3
stdlib 344.06k (  2.91µs) (± 0.90%)  16.0kB/op        fastest
  SWAR 340.31k (  2.94µs) (± 0.66%)  11.7kB/op   1.01× slower

300x10
stdlib 240.64k (  4.16µs) (± 0.68%)  16.0kB/op   1.26× slower
  SWAR 303.43k (  3.30µs) (± 0.85%)  11.7kB/op        fastest

300x30
stdlib 128.06k (  7.81µs) (± 0.68%)  20.7kB/op   1.73× slower
  SWAR 222.07k (  4.50µs) (± 0.41%)  16.4kB/op        fastest

300x100
stdlib  46.43k ( 21.54µs) (± 3.09%)  44.1kB/op   2.85× slower
  SWAR 132.17k (  7.57µs) (± 1.19%)  39.8kB/op        fastest

300x300
stdlib  18.01k ( 55.52µs) (± 3.38%)   101kB/op   3.12× slower
  SWAR  56.15k ( 17.81µs) (± 0.90%)  96.1kB/op        fastest

300x1000
stdlib   5.68k (175.99µs) (± 2.30%)  307kB/op   3.18× slower
  SWAR  18.09k ( 55.27µs) (± 0.50%)  303kB/op        fastest

1000x1
stdlib 147.07k (  6.80µs) (± 0.84%)  35.6kB/op        fastest
  SWAR 132.05k (  7.57µs) (± 0.59%)  23.4kB/op   1.11× slower

1000x3
stdlib 107.92k (  9.27µs) (± 2.11%)  51.3kB/op        fastest
  SWAR 104.95k (  9.53µs) (± 0.68%)  39.1kB/op   1.03× slower

1000x10
stdlib  75.57k ( 13.23µs) (± 0.70%)  51.3kB/op   1.27× slower
  SWAR  95.67k ( 10.45µs) (± 0.95%)  39.1kB/op        fastest

1000x30
stdlib  38.65k ( 25.88µs) (± 1.14%)  66.9kB/op   1.72× slower
  SWAR  66.44k ( 15.05µs) (± 1.38%)  54.7kB/op        fastest

1000x100
stdlib  13.77k ( 72.64µs) (± 2.30%)  145kB/op   2.84× slower
  SWAR  39.12k ( 25.57µs) (± 0.87%)  133kB/op        fastest

1000x300
stdlib   5.23k (191.25µs) (± 2.53%)  332kB/op   2.91× slower
  SWAR  15.20k ( 65.78µs) (± 0.68%)  320kB/op        fastest

1000x1000
stdlib   1.75k (570.49µs) (± 4.06%)  0.99MB/op   3.23× slower
  SWAR   5.66k (176.77µs) (± 0.59%)  0.98MB/op        fastest

I can open it as yet another PR if that’s helpful.

Some more implementations in other languages:

  • Ruby (called here): memchr for single bytes, rolling hash or memmem for separators up to sizeof(size_t) bytes, Sunday otherwise
  • Python (called here): memchr for single bytes but plain loop for subjects shoter than MEMCHR_CUT_OFF bytes, modified Boyer-Moore / Horspool for short subjects or separators, two-way algorithm for long matches, and an adaptive algorithm between the two when the separator is longer than 33% of the subject
  • Rust (called here): memchr for characters including multibyte ones, two-way algorithm for strings
  • Go (called here): custom memchr for single bytes, custom assembly for very short matches, adaptive fallback to Rabin-Karp for long matches