Skip to content

Iterating over a string builder by index becomes ~exponentially slow for large builders #24395

Closed

Description

@danmoseley

This is motivated by this issue originally opened against MSBuild, which internally in its reuseable string builder factory compares string builders by index. It happens to start from the end, but that doesn't seem to matter. The opener observed this can be enormously slower that creating a string first.

Using this benchmark program I get the following results:

BenchmarkDotNet=v0.10.11, OS=Windows 10 Redstone 3 [1709, Fall Creators Update] (10.0.16299.98)
Processor=Intel Xeon CPU E5-1620 0 3.60GHz, ProcessorCount=8
Frequency=3507174 Hz, Resolution=285.1299 ns, Timer=TSC
  [Host]     : .NET Framework 4.7 (CLR 4.0.30319.42000), 32bit LegacyJIT-v4.7.2600.0
  DefaultJob : .NET Framework 4.7 (CLR 4.0.30319.42000), 32bit LegacyJIT-v4.7.2600.0


          Method |    N |         Mean |       Error |      StdDev |
---------------- |----- |-------------:|------------:|------------:|
   ToStringFirst |  100 |     2.028 us |   0.0403 us |   0.0465 us |
 IndexedBackward |  100 |    21.399 us |   0.4123 us |   0.5063 us |
  IndexedForward |  100 |    23.020 us |   0.4461 us |   0.4581 us |
   ToStringFirst | 1000 |    43.210 us |   0.5032 us |   0.4202 us |
 IndexedBackward | 1000 |   323.959 us |   7.3317 us |   6.8581 us |
  IndexedForward | 1000 |   335.538 us |   6.3072 us |   5.8997 us |
   ToStringFirst | 5000 |   217.657 us |   4.1058 us |   4.3931 us |
 IndexedBackward | 5000 | 6,542.491 us | 131.7964 us | 223.8003 us |
  IndexedForward | 5000 | 6,301.246 us | 102.0129 us |  90.4317 us |

64 bit

BenchmarkDotNet=v0.10.11, OS=Windows 10 Redstone 3 [1709, Fall Creators Update] (10.0.16299.98)
Processor=Intel Xeon CPU E5-1620 0 3.60GHz, ProcessorCount=8
Frequency=3507174 Hz, Resolution=285.1299 ns, Timer=TSC
  [Host]     : .NET Framework 4.7 (CLR 4.0.30319.42000), 64bit RyuJIT-v4.7.2600.0
  DefaultJob : .NET Framework 4.7 (CLR 4.0.30319.42000), 64bit RyuJIT-v4.7.2600.0


          Method |    N |         Mean |       Error |      StdDev |
---------------- |----- |-------------:|------------:|------------:|
   ToStringFirst |  100 |     1.944 us |   0.0369 us |   0.0410 us |
 IndexedBackward |  100 |    22.955 us |   0.2145 us |   0.1902 us |
  IndexedForward |  100 |    22.674 us |   0.2493 us |   0.2332 us |
   ToStringFirst | 1000 |    40.258 us |   0.4520 us |   0.4007 us |
 IndexedBackward | 1000 |   325.231 us |   4.6369 us |   4.3374 us |
  IndexedForward | 1000 |   332.819 us |   8.0014 us |   7.4845 us |
   ToStringFirst | 5000 |   200.979 us |   1.2610 us |   1.1179 us |
 IndexedBackward | 5000 | 7,950.240 us | 157.2641 us | 253.9522 us |
  IndexedForward | 5000 | 8,137.382 us | 161.3316 us | 251.1739 us |

Clearly indexing is going to have a lot of overhead such as bounds checking. But I would expect that to be proportional to N, and we see a big jump when N gets large. Related to fragmentation of the SB? Can we do better?

Activity

  1. danmoseley commented on Dec 8, 2017

    @danmoseley
    ContributorAuthor

    MSBuild has this code here and here

    This would not show up unless the MSBuild is expanding eg., large lists (such as the content of a large-ish file read in as items) which is not uncommon but quite likely isn't showing up in the ASP.NET scenarios we're looking at right now.

    @davkean

  2. AndyAyersMS commented on Dec 8, 2017

    @AndyAyersMS
    Member

    You might see different behavior on Core than on Desktop here.

  3. danmoseley commented on Dec 8, 2017

    @danmoseley
    ContributorAuthor

    ToString (or string comparison) is measurably faster on Core, but indexing seems just as slow.

    BenchmarkDotNet=v0.10.11, OS=Windows 10 Redstone 3 [1709, Fall Creators Update] (10.0.16299.98)
    Processor=Intel Xeon CPU E5-1620 0 3.60GHz, ProcessorCount=8
    Frequency=3507174 Hz, Resolution=285.1299 ns, Timer=TSC
    .NET Core SDK=2.1.2
      [Host]     : .NET Core 2.0.3 (Framework 4.6.25815.02), 64bit RyuJIT
      DefaultJob : .NET Core 2.0.3 (Framework 4.6.25815.02), 64bit RyuJIT
    
    
              Method |    N |         Mean |       Error |      StdDev |
    ---------------- |----- |-------------:|------------:|------------:|
       ToStringFirst |  100 |     1.516 us |   0.0174 us |   0.0154 us |
     IndexedBackward |  100 |    21.894 us |   0.3194 us |   0.2988 us |
      IndexedForward |  100 |    22.972 us |   0.3801 us |   0.3556 us |
       ToStringFirst | 1000 |    39.239 us |   0.1815 us |   0.1515 us |
     IndexedBackward | 1000 |   324.277 us |   5.4631 us |   5.1102 us |
      IndexedForward | 1000 |   324.691 us |   3.3203 us |   3.1058 us |
       ToStringFirst | 5000 |   201.110 us |   3.0653 us |   2.8673 us |
     IndexedBackward | 5000 | 7,822.458 us | 141.3221 us | 125.2783 us |
      IndexedForward | 5000 | 7,552.085 us | 146.0999 us | 184.7696 us |
    
  4. danmoseley commented on Dec 8, 2017

    @danmoseley
    ContributorAuthor

    Oops, desktop was on 32 bit. Let me try 64 bit.

  5. danmoseley commented on Dec 8, 2017

    @danmoseley
    ContributorAuthor

    Edited to add 64 bit for desktop above. Now Core and Desktop are basically identical.

  6. danmoseley commented on Dec 8, 2017

    @danmoseley
    ContributorAuthor

    The discrepancy is much smaller when the StringBuilder is presized (gist):

    BenchmarkDotNet=v0.10.11, OS=Windows 10 Redstone 3 [1709, Fall Creators Update] (10.0.16299.98)
    Processor=Intel Xeon CPU E5-1620 0 3.60GHz, ProcessorCount=8
    Frequency=3507174 Hz, Resolution=285.1299 ns, Timer=TSC
      [Host]     : .NET Framework 4.7 (CLR 4.0.30319.42000), 64bit RyuJIT-v4.7.2600.0
      DefaultJob : .NET Framework 4.7 (CLR 4.0.30319.42000), 64bit RyuJIT-v4.7.2600.0
    
    
             Method |    N |       Mean |      Error |     StdDev |
    --------------- |----- |-----------:|-----------:|-----------:|
      ToStringFirst |  100 |   1.810 us |  0.0298 us |  0.0279 us |
     IndexedForward |  100 |  16.534 us |  0.2751 us |  0.2573 us |
      ToStringFirst | 1000 |  40.155 us |  0.7265 us |  0.6440 us |
     IndexedForward | 1000 | 166.396 us |  3.3039 us |  3.8047 us |
      ToStringFirst | 5000 | 206.052 us |  4.0090 us |  6.1221 us |
     IndexedForward | 5000 | 848.451 us | 12.1369 us | 11.3528 us |
    

    Both 1000 and 5000 are approximately 4x, suggesting a more natural linear effect consistent with eg., bounds checks.

  7. danmoseley commented on Dec 8, 2017

    @danmoseley
    ContributorAuthor

    This is presumably because every index has to walk the chain of chunks, checking the offset for each until it finds the one with the indexed location. In the 5000 case above, there are 250K characters so 32 chunks of 8000 characters. On average it has to loop 16 times to find the right chunk - for every index. No doubt this was a conscious compromise when making stringbuilder chunky. @vancem ?

  8. AndyAyersMS commented on Dec 9, 2017

    @AndyAyersMS
    Member

    When we did stuff like this in the past (for chunky sparse bitvectors in a now-defunct compiler) we would cache the result of the last index lookup; subsequent index values tended to be "nearby". Random access would still be slow but this happens less often, but sequential accesses were now good.

    This might require some invalidation logic elsewhere if the chunk structure can be messed with.

  9. danmoseley commented on Dec 9, 2017

    @danmoseley
    ContributorAuthor

    Another option is to store the chunk offsets in a shared array that could be binary searched. But I like your idea, it is simple and unlikely to hurt other cases.

  10. danmoseley commented on Dec 11, 2017

    @danmoseley
    ContributorAuthor

    The user always works against the StringBuilder that owns the end chunk and that has is a singly linked list of chunks back to the start (so incidentally the smaller the index the more blocks to traverse to find it).

    In the first idea, there would be two fields in the end StringBuilder: the last accessed index and a pointer to the chunk for it. Whenever the chunk chain is changed (by setting m_ChunkPrevious) those would need to be invalidated with some O(1) math.

    This scheme could also speed up FindChunkForIndex and FindChunkForByte by reducing (on average halving) the space they need to walk (using those might not actually set the last accessed, just read it). But for indexing anywhere near the last index, it would change O(chunks) to O(1).

    @vancem @jkotas does this seem a reasonable approach and unlikely to measurably affect other perf?

    Ideally these 2 fields could somehow be put to use in the StringBuilders representing the chunks other than the final one.

  11. jkotas commented on Dec 11, 2017

    @jkotas
    Member

    Would it be better to just document that StringBuilder indexer is not fast?

    StringBuilder is designed for building strings. It is not designed to inspect them - it will never work great for that. And trying to make it work ok for that just hurts the primary purpose what it is meant to be used for.

  12. danmoseley commented on Dec 11, 2017

    @danmoseley
    ContributorAuthor

    Indexing as fast as a string or array would compromise its goals for sure. I just hate to think of people getting O(n^2) indexing when a few field gets and sets could get it to O(n) and potentially help other operations like StartsWith. If not the pit of success then at least the pit of mediocrity.

    I looked at Azure profiler data and fortunately the usage of get_Chars, set_Chars, StartsWith, FindChunkForIndex are vastly lower than Append, etc. So I'll close this for now...

  13. Alois-xx commented on Dec 12, 2017

    @Alois-xx
    Contributor

    This should definitely be fixed. I have some build targets which have 8 minutes of "compile" time due to a large string with 900KB length which is compared in MSBuild properties. The resulting string is always the same but trying to intern it takes much longer than to create it and be done with with it. The actual work is < 40s which has definitely some room for improvement.

    The MSBuild syntax to batch targets could definitely help to prevent stuffing a large list into a single string but the syntax is rather awkward and only understood by a few. This did lead to less than optimal msbuild targets which were wasting a significant amount of time and computing power at our dev shop.

  14. davkean commented on Dec 12, 2017

    @davkean
    Member

    @Alois-xx It would be better to file that issue over on MSBuild so that we can fix its interning behavior. Fixing that issue at the StringBuilder level isn't the right change.

  15. 3 remaining items

  16. Alois-xx commented on Dec 12, 2017

    @Alois-xx
    Contributor

    @danmosemsft: Sure. I need something to work with over christmas.
    But first I deleted the the single line in our msbuild target file which was only there for a consistency check where no one remembers what it is actually good for.

  17. jkotas commented on May 4, 2018

    @jkotas
    Member

    @MohammadHamdyGhanem The efficient string builder iterator was proposed and being worked on by @vancem in https://github.com/dotnet/corefx/issues/21248 / dotnet/coreclr#17530.

  18. jkotas commented on May 5, 2018

    @jkotas
    Member

    None of the proposals considered had the characteristics you are describing. The latest version of the proposal is in fact enumerator. Check the proposed implementation in dotnet/coreclr#17530.

  19. stephentoub commented on May 5, 2018

    @stephentoub
    Member

    The first proposal didn't have those characteristics, either.

  20. transferred this issue fromdotnet/corefxon Jan 31, 2020
  21. added this to the 2.1.0 milestone on Jan 31, 2020
  22. ghost locked as resolved and limited conversation to collaborators on Dec 19, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area-System.RuntimeenhancementProduct code improvement that does NOT require public API changes/additionstenet-performancePerformance related issue

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions