Performance of measuring text width in AppKit
Asked Answered
M

1

11

Is there a way in AppKit to measure the width of a large number of NSString objects(say a million) really fast? I have tried 3 different ways to do this:

  • [NSString sizeWithAttributes:]
  • [NSAttributedString size]
  • NSLayoutManager (get text width instead of height)

    Here are some performance metrics
    Count\Mechanism    sizeWithAttributes    NSAttributedString    NSLayoutManager
    1000               0.057                 0.031                 0.007
    10000              0.329                 0.325                 0.064
    100000             3.06                  3.14                  0.689
    1000000            29.5                  31.3                  7.06



    NSLayoutManager is clearly the way to go, but the problem being

  • High memory footprint(more than 1GB according to profiler) because of the creation of heavyweight NSTextStorage objects.
  • High creation time. All of the time taken is during creation of the above strings, which is a dealbreaker in itself.(subsequently measuring NSTextStorage objects which have glyphs created and laid out only takes about 0.0002 seconds).
  • 7 seconds is still too slow for what I am trying to do. Is there a faster way? To measure a million strings in about a second?

    In case you want to play around, Here is the github project.

  • Marotta answered 29/5, 2015 at 19:40 Comment(3)
    I quick experiment: I found that I could reduce the memory to 23 MB vs > 1 GB by operating on chunks of 100 x 10000 strings while keeping performance almost the same by creating the NSTextStorage objects once, and using -[NSMutableString replaceCharactersInRange:withString:] to replace their contents after they're used once.Arcadia
    (Maybe further breaking down the arrays would let you parallelize in an optimal way per @rob's suggestion)Arcadia
    @Arcadia reusing NSTextStorage objects does bring down memory usage, but it increases time taken to measure, since now we need to create glyphs and layout every time we change the text storage's string. Compared to my best implementation where there were a million text storage objects but only 1 text container. I have added a new method with your suggestion into the github project, if you want to take a look. Thanks a lot for your suggestion though.Marotta
    R
    3

    Here are some ideas I haven't tried.

    1. Use Core Text directly. The other APIs are built on top of it.

    2. Parallelize. All modern Macs (and even all modern iOS devices) have multiple cores. Divide up the string array into several subarrays. For each subarray, submit a block to a global GCD queue. In the block, create the necessary Core Text or NSLayoutManager objects and measure the strings in the subarray. Both APIs can be used safely this way. (Core Text) (NSLayoutManager)

    3. Regarding “High memory footprint”: Use Local Autorelease Pool Blocks to Reduce Peak Memory Footprint.

    4. Regarding “All of the time taken is during creation of the above strings, which is a dealbreaker in itself”: Are you saying all the time is spent in these lines:

      double random = (double)arc4random_uniform(1000) / 1000;
      NSString *randomNumber = [NSString stringWithFormat:@"%f", random];
      

      Formatting a floating-point number is expensive. Is this your real use case? If you just want to format a random rational of the form n/1000 for 0 ≤ n < 1000, there are faster ways. Also, in many fonts, all digits have the same width, so that it's easy to typeset columns of numbers. If you pick such a font, you can avoid measuring the strings in the first place.

    UPDATE

    Here's the fastest code I've come up with using Core Text. The dispatched version is almost twice as fast as the single-threaded version on my Core i7 MacBook Pro. My fork of your project is here.

    static CGFloat maxWidthOfStringsUsingCTFramesetter(
            NSArray *strings, NSRange range) {
        NSString *bigString =
            [[strings subarrayWithRange:range] componentsJoinedByString:@"\n"];
        NSAttributedString *richText =
            [[NSAttributedString alloc]
                initWithString:bigString
                attributes:@{ NSFontAttributeName: (__bridge NSFont *)font }];
        CGPathRef path =
            CGPathCreateWithRect(CGRectMake(0, 0, CGFLOAT_MAX, CGFLOAT_MAX), NULL);
        CGFloat width = 0.0;
        CTFramesetterRef setter =
            CTFramesetterCreateWithAttributedString(
                (__bridge CFAttributedStringRef)richText);
        CTFrameRef frame =
            CTFramesetterCreateFrame(
                setter, CFRangeMake(0, bigString.length), path, NULL);
        NSArray *lines = (__bridge NSArray *)CTFrameGetLines(frame);
        for (id item in lines) {
            CTLineRef line = (__bridge CTLineRef)item;
            width = MAX(width, CTLineGetTypographicBounds(line, NULL, NULL, NULL));
        }
        CFRelease(frame);
        CFRelease(setter);
        CFRelease(path);
        return (CGFloat)width;
    }
    
    static void test_CTFramesetter() {
        runTest(__func__, ^{
            return maxWidthOfStringsUsingCTFramesetter(
                testStrings, NSMakeRange(0, testStrings.count));
        });
    }
    
    static void test_CTFramesetter_dispatched() {
        runTest(__func__, ^{
            dispatch_queue_t gatherQueue = dispatch_queue_create(
                "test_CTFramesetter_dispatched result-gathering queue", nil);
            dispatch_queue_t runQueue =
                dispatch_get_global_queue(QOS_CLASS_UTILITY, 0);
            dispatch_group_t group = dispatch_group_create();
    
            __block CGFloat gatheredWidth = 0.0;
    
            const size_t Parallelism = 16;
            const size_t totalCount = testStrings.count;
            // Force unsigned long to get 64-bit math to avoid overflow for
            // large totalCounts.
            for (unsigned long i = 0; i < Parallelism; ++i) {
                NSUInteger start = (totalCount * i) / Parallelism;
                NSUInteger end = (totalCount * (i + 1)) / Parallelism;
                NSRange range = NSMakeRange(start, end - start);
                dispatch_group_async(group, runQueue, ^{
                    double width =
                        maxWidthOfStringsUsingCTFramesetter(testStrings, range);
                    dispatch_sync(gatherQueue, ^{
                        gatheredWidth = MAX(gatheredWidth, width);
                    });
                });
            }
    
            dispatch_group_wait(group, DISPATCH_TIME_FOREVER);
    
            return gatheredWidth;
        });
    }
    
    Reyesreykjavik answered 31/5, 2015 at 20:44 Comment(10)
    1) I have been looking at core text, closest I could find is a way to convert characters to glyphs using CTRun. But these glyphs are not Font size dependent, I can't figure out how to get the size of the glyphs(assuming I ignore ligatures and kerning which is done by the layout process)Marotta
    3) Thanks. Though looking at Allocations in Profiler, about 250-400 MB was taken up by the NSTextStorage objects I was able to free up about 100-150MB. This however didn't affect the time at all.Marotta
    4) I meant the time taken to create NSTextStorage objects, not the random numbers. I do realize that almost all fonts have numbers with equal spacing(certainly true for apple default fonts). But lets assume the strings are made up with non-alphanumeric characters.Marotta
    2) I am pretty sure instantiating several NSTextStorage objects and adding layout manager to it in different threads, will cause locks or potential crashes since NSLayoutManager isn't thread safe. However NSTextStorage objects could probably be 'created' in multiple threads and the layout manager be added on the main thread. I will work on this solution.Marotta
    The logistics of that is kinda unpractical. Not only does cocoa not guarantee a thread for every dispatch_async(new_queue), NSLayoutManager is a super heavy-weight object.Marotta
    Sorry, I meant create a new layout manager in each dispatched block. What's “super-heavyweight”? You're already creating 400 MB of text storage.Reyesreykjavik
    so I tried to create a new NSLayoutManager, NSTextStorage, NSTextContainer for every string to be measured, 'chunked' them, autoreleased them. The memory footprint is great, but it went from taking 7 to 32 seconds.Marotta
    I do think Core Text is the way to go, but I just can't figure out how. If you point me in the right direction I will mark this as answer as I feel we digressed a lot. But I really appreciate your help!Marotta
    so I had fixed the problem without using core text. My solution involved your suggestion and @nielsbot's and some of mine(it uses AppKit and not Core Text). Check out my solution in github. Your CT_Framesetter_dispatched performs at 0.77 seconds. Mine takes 0.03 for creation and 0.19 for measuring for a total of 0.22 seconds!Marotta
    Thanks for your solution. I really like it, since it explains the inner workings better.Marotta

    © 2022 - 2024 — McMap. All rights reserved.