HarfBuzz Study:
The case of the slow hb-ft h_advance function

behdad
July 29, 2022

Introduction

We investigate why measuring the horizontal advance-width of glyphs via FreeType font-functions in HarfBuzz is ten times slower than using the internal HarfBuzz font-functions.

I scratched my head for a good while on this since on the surface nothing stood out: both functions were doing the same work, albeit in the case of FreeType, a bit scattered around…

Methodology

Using the benchmark_font benchmark in the HarfBuzz performance test suite, using the Roboto-Regular.ttf test case, which exercises the simplest of advance-width access, just fetching numbers from the hmtx table, with no variations or hinting (HarfBuzz does not do hinting):

$ perf/benchmark-font --benchmark_filter=glyph_h_advances/Roboto-Regular.ttf/[hf]

-----------------------------------------------------------------------------------------

Benchmark Time CPU Iterations

-----------------------------------------------------------------------------------------

BM_Font/glyph_h_advances/Roboto-Regular.ttf/hb 1.98 us 1.97 us 356240

BM_Font/glyph_h_advances/Roboto-Regular.ttf/ft 19.5 us 19.5 us 35873

The test case code is this:

case glyph_h_advances:

{

hb_codepoint_t *glyphs = (hb_codepoint_t *) calloc (num_glyphs, sizeof (hb_codepoint_t));

hb_position_t *advances = (hb_position_t *) calloc (num_glyphs, sizeof (hb_codepoint_t));

for (unsigned g = 0; g < num_glyphs; g++)

glyphs[g] = g;

for (auto _ : state)

hb_font_get_glyph_h_advances (font,

num_glyphs,

glyphs, sizeof (*glyphs),

advances, sizeof (*advances));

free (advances);

free (glyphs);

break;

}

It makes a HarfBuzz call to fetch the advance width of all glyphs in the font. Only the call of interest, hb_font_get_glyph_h_advances(), is measured.

The font at hand has 1294 glyphs in total. For hb-ot-font that means an average of 1.5ns per glyph, whereas for hb-ft an average of 15ns. Given that I am running this on a 4.6GHz machine, ignoring any pipelining and memory stall effects, this translates roughly to ~7 instructions per glyph for hb-ot-font and ~70 for hb-ft. As we will see below, these sound in the ballpark of the work actually done and no mystery to explain other than fat.

Assessment

Advance-width cache

Both FreeType (called hb-ft) and HarfBuzz-internal (called hb-ot-font) font functions use a cache. Because we are fetching advance-width of each glyph exactly once in the benchmark, the test bypasses this cache completely. However, since the hb-ot-font code is so fast in the simple case (like this), it uses a separate loop without the cache, so the cache overhead is not present. The hb-ft case always goes through the cache because in realistic cases (shaping, not benchmarking), that is beneficial. We will measure the cache overhead later.

Involved code

The hb-ot-font code involved is:

for (unsigned int i = 0; i < count; i++)

{

*first_advance = font->em_scale_x (hmtx.get_advance_with_var_unscaled (*first_glyph, font, nullptr));

first_glyph = &StructAtOffsetUnaligned<hb_codepoint_t> (first_glyph, glyph_stride);

first_advance = &StructAtOffsetUnaligned<hb_position_t> (first_advance, advance_stride);

}

For each glyph, hmtx.get_advance_with_var_unscaled() is called and the result is scaled. The code for that function is inlined into the loop. Moreover, that code, for the case of valid glyph indices, boils down to just a few operations:

if (glyph < num_bearings)

return table->longMetricZ[hb_min (glyph, (uint32_t) num_long_metrics - 1)].advance;

That explains why it is so fast.

Now let’s look into the hb-ft code:

for (unsigned int i = 0; i < count; i++)

{

FT_Fixed v = 0;

hb_codepoint_t glyph = *first_glyph;

unsigned int cv;

if (ft_font->advance_cache.get (glyph, &cv))

v = cv;

else

{

FT_Get_Advance (ft_face, glyph, load_flags, &v);

ft_font->advance_cache.set (glyph, v);

}

*first_advance = (int) (v * mult + (1<<9)) >> 10;

first_glyph = &StructAtOffsetUnaligned<hb_codepoint_t> (first_glyph, glyph_stride);

first_advance = &StructAtOffsetUnaligned<hb_position_t> (first_advance, advance_stride);

}

It is similar in that for each glyph it makes a call to FT_Get_Advance() and scales the result. But it also uses the cache.

Measuring the fat

Profile

Before we start guessing, let’s look into a profile of both runs to see where the majority of the time is spent.

For hb-ot-font:

99.61% benchmark-font libharfbuzz.so.0.50001.0 [.] hb_ot_get_glyph_h_advances

As we expect, all the time is spent in the main function, as everything is inlined.

For hb-ft:

33.33% benchmark-font libharfbuzz.so.0.50001.0 [.] hb_ft_get_glyph_h_advances

21.97% benchmark-font libfreetype.so.6.17.4 [.] FT_MulDiv

14.97% benchmark-font libfreetype.so.6.17.4 [.] tt_face_get_metrics

9.81% benchmark-font libfreetype.so.6.17.4 [.] FT_Get_Advance

8.63% benchmark-font libfreetype.so.6.17.4 [.] tt_get_advances

8.19% benchmark-font libfreetype.so.6.17.4 [.] FT_Stream_ReadUShort

This is more interesting. One third of the time is spent in the hb-ft function (~6.35us), while the rest is spent in various FreeType functions. The hb-ft overhead is still three times of what the hb-ot-font spends for all its work. We expect this to be the cache and scaling overhead done in hb-ft. Let’s measure that next.

Cache overhead

If we disable the cache in hb-ft:

BM_Font/glyph_h_advances/Roboto-Regular.ttf/ft 17.9 us 17.8 us 39370

That saved 1.6us. Removing the scaling does not seem to produce any further speedup.

The loop overhead (FT_Get_Advance()) is about .75us for an empty loop. That still leaves about 4us to explain in this function. The call overhead to get into FreeType and back will be accounted for below. The rest of the 4us, I can only assume, is cache effects of moving all our code out of the cache while FreeType is doing its work, whether in the hb-ot-font case the whole loop stays in cache.

Scaling overhead

While the hb-ft side scaling, (int) (v * mult + (1<<9)) >> 10, (mult is a float) does not seem to have measurable overhead, the FreeType-side scaling (FT_MulDiv) shows up in the profile consuming 22% of the time (~4us). This is an impedance-mismatch between hb-ot-font and hb-ft, since we do not have to ask FreeType to scale things, since we do our own scaling.

Modifying hb-ft transiently to ask FreeType to not scale (FT_LOAD_NO_SCALE) indeed removes 2us overhead and removes FT_MulDiv from the profile:

BM_Font/glyph_h_advances/Roboto-Regular.ttf/ft 17.5 us 17.4 us 40196

Why we don’t see the full 4us benefit I have no idea.

Call overhead

Let’s now track the call chain into FreeType’s bowels before the result is produced.

The entry point FT_Get_Advance() does:

if ( !face )

return FT_THROW( Invalid_Face_Handle );

if ( !padvance )

return FT_THROW( Invalid_Argument );

if ( gindex >= (FT_UInt)face->num_glyphs )

return FT_THROW( Invalid_Glyph_Index );

func = face->driver->clazz->get_advances;

if ( func && LOAD_ADVANCE_FAST_CHECK( face, flags ) )

{

FT_Error error;

error = func( face, gindex, 1, flags, padvance );

if ( !error )

return _ft_face_scale_advances( face, padvance, 1, flags );

if ( FT_ERR_NEQ( error, Unimplemented_Feature ) )

return error;

}

The func() here adds another indirection and resolves to tt_get_advances(), which does:

for ( nn = 0; nn < count; nn++ )

{

FT_Short lsb;

FT_UShort aw;

TT_Get_HMetrics( face, start + nn, &lsb, &aw );

advances[nn] = aw;

}

The count is 1 here. The next call is into TT_Get_HMetrics(), which does:

( (SFNT_Service)face->sfnt )->get_metrics( face, 0, idx, lsb, aw );

Which resolves to tt_face_get_metrics(), which actually accesses the hmtx table and returns the result.

To summarize, the chain of calls is:

hb_ft_get_glyph_h_advances()

FT_Get_Advance()

tt_get_advances()

TT_Get_HMetrics()

tt_face_get_metrics()

That’s a lot of calls! Now let’s try to measure the overhead of each extra indirection. If I make the final call (tt_face_get_metrics()) return immediately, the timing would be:

BM_Font/glyph_h_advances/Roboto-Regular.ttf/ft 10.9 us 10.9 us 64347

If I instead return in TT_Get_HMetrics() just before calling into the next function, then the timing would be:

BM_Font/glyph_h_advances/Roboto-Regular.ttf/ft 9.77 us 9.74 us 71749

These numbers are very stable when I repeat the benchmark. So, there is over a 1us overhead just for this extra indirection:

( (SFNT_Service)face->sfnt )->get_metrics( face, 0, idx, lsb, aw );

And we are doing four of them! Each of those functions, except for the one-line TT_Get_HMetrics() also does a bunch of sanity checks, many times redundant given that the previous function also did the same. These all add up. Indeed, as we are observing, the actual work in tt_face_get_metrics() takes only 9us. The time in hb-ft takes 6us, and 4us is the call overhead inside FreeType.

Table-access overhead

Now FreeType is finally ready to do the actual work.

The code in tt_face_get_metrics(), for valid-glyph-index cases, boils down to:

if ( k > 0 )

{

if ( gindex < (FT_UInt)k )

{

table_pos += 4 * gindex;

if ( table_pos + 4 > table_end )

goto NoData;

if ( FT_STREAM_SEEK( table_pos ) ||

FT_READ_USHORT( *aadvance ) ||

FT_READ_SHORT( *abearing ) )

goto NoData;

}

Contrast to the hb-ot-font code:

if (glyph < num_bearings)

return table->longMetricZ[hb_min (glyph, (uint32_t) num_long_metrics - 1)].advance;

Now we see what is going on. FreeType is doing seek and read operations, which in most cases do not involve any OS-level seek and read operations but just memory access. However, they still incur significant overhead. Again, this is a design-difference between FreeType and HarfBuzz. FreeType allows incremental font loading, versus HarfBuzz requires loading fonts one table at a time, so it can do bounds checking in advance and hence access memory unguarded.

Let’s look at the FT_READ_USHORT implementation for mmap()ed implementation. That macro expands to yet another function call, into FT_Stream_ReadUShort(). So we have another three function call overheads here.

FT_Stream_ReadUShort() reads:

FT_Stream_ReadUShort( FT_Stream stream,

FT_Error* error )

{

FT_Byte reads[2];

FT_Byte* p;

FT_UInt16 result = 0;

FT_ASSERT( stream );

if ( stream->pos + 1 < stream->size )

{

if ( stream->read )

{

if ( stream->read( stream, stream->pos, reads, 2L ) != 2L )

goto Fail;

p = reads;

}

else

p = stream->base + stream->pos;

if ( p )

result = FT_NEXT_USHORT( p );

}

else

goto Fail;

stream->pos += 2;

*error = FT_Err_Ok;

return result;

Fail:

*error = FT_THROW( Invalid_Stream_Operation );

FT_ERROR(( "FT_Stream_ReadUShort:"

" invalid i/o; pos = 0x%lx, size = 0x%lx\n",

stream->pos, stream->size ));

return result;

}

In the common case where stream->read is NULL, there’s no surprises there, it does pointer math and reads four bytes. So I’m guessing that it’s the function call overheads that is adding up, plus extra checks, plus reading the bytes one-by-one, whereas HarfBuzz uses a ushort-sized read & byteswap.

Another overhead to point out here is that FreeType is reading both advance-width and lsb, even though the call chain is only interested in the advance width. Commenting out the FT_READ_SHORT( *abearing ) portion shows a 2us speedup, suggesting that each of those macro/function calls is taking 2us, of which supposedly 1us is just the function call overhead.

So the table access that is 2us in HarfBuzz, ends up being closer to 6us in FreeType. Another 3us in that function is just access to various structures to get to the table start & end. These are cached in HarfBuzz via an accelerator device.

Summary of fat

As we observed, hb-ft is spending 19.5us for the same operation that hb-ot-font is spending 2us on:

$ perf/benchmark-font --benchmark_filter=glyph_h_advances/Roboto-Regular.ttf/[hf]

-----------------------------------------------------------------------------------------

Benchmark Time CPU Iterations

-----------------------------------------------------------------------------------------

BM_Font/glyph_h_advances/Roboto-Regular.ttf/hb 1.98 us 1.97 us 356240

BM_Font/glyph_h_advances/Roboto-Regular.ttf/ft 19.5 us 19.5 us 35873

This 19.5us can be broken down into:

Conclusions

Does this matter

It does not matter in the case of the h_advance function because the work is so small and the function so fast even in the case of FreeType. Indeed, that is why the overhead is 90% in FreeType’s case. In heavier operations we do not observe such discrepancy.

Specially, in any operation where loading glyphs is involved (majority of FreeType’s use-case), the call overhead is insignificant.

That said, in the benchmark-font we do observe that for all apples-to-apples comparisons, HarfBuzz comes ahead as the faster of the two, quite possibly because of the same principles. We discuss these principles in the next section.

Note that FreeType provides FT_Get_Advances() to access advances of multiple glyphs, supposedly to reduce function call overhead. However, this version can only fetch advances of a consecutive block of glyph indices and is adequate for caching such glyph advances on the client side and not relevant to our use-case.

Design matters

Sanitize once, access always

A core design of HarfBuzz is that we sanitize tables upon first access, fix up if necessary, and access them without any bounds checking from there on. This means we do not pay for any bounds checking during access.

For some tables this needs keeping an accelerator device, a small auxiliary struct. The hmtx table, involved in h_advance call, is one such struct. The code in hb-ot-font:

if (glyph < num_bearings)

return table->longMetricZ[hb_min (glyph, (uint32_t) num_long_metrics - 1)].advance;

is in a method of the hmtx accelerator structure. The accelerator has already made sure that if (glyph < num_bearings), then it is safe to evaluate the return line with no extra checks necessary.

This design is key to the speed of HarfBuzz.

Speed versus memory

FreeType, for certain tables, pre-loads all data into memory, which results in fast access, at the cost of excess memory. This is a per-table choice in FreeType. One choice is faster, one is more memory efficient. The HarfBuzz design however has the best of both worlds. Indeed, when HarfBuzz was rewritten to use this design, it showed a 99% reduction in memory usage.

Costly abstractions

FreeType is internally designed in terms of modules and services. That is where the multiple function-call indirections are coming from. Each abstraction layer does its own sanity checks on the input arguments. As we demonstrated, this does not come for free.

In HarfBuzz, we forgo such abstractions. For example, the hb-ot-font glyph shape loader is simply written this way:

{

hb_draw_session_t draw_session (draw_funcs, draw_data, font->slant_xy);

if (font->face->table.glyf->get_path (font, glyph, draw_session)) return;

#ifndef HB_NO_CFF

if (font->face->table.cff1->get_path (font, glyph, draw_session)) return;

if (font->face->table.cff2->get_path (font, glyph, draw_session)) return;

#endif

}

Or, getting extents:

{

const hb_ot_font_t *ot_font = (const hb_ot_font_t *) font_data;

const hb_ot_face_t *ot_face = ot_font->ot_face;

#if !defined(HB_NO_OT_FONT_BITMAP) && !defined(HB_NO_COLOR)

if (ot_face->sbix->get_extents (font, glyph, extents)) return true;

#endif

if (ot_face->glyf->get_extents (font, glyph, extents)) return true;

#ifndef HB_NO_OT_FONT_CFF

if (ot_face->cff1->get_extents (font, glyph, extents)) return true;

if (ot_face->cff2->get_extents (font, glyph, extents)) return true;

#endif

#if !defined(HB_NO_OT_FONT_BITMAP) && !defined(HB_NO_COLOR)

if (ot_face->CBDT->get_extents (font, glyph, extents)) return true;

#endif

// TODO Hook up side-bearings variations.

return false;

}

The code for the invoked functions are inlined and each of those is fast to fail. We find this style of code more intuitive to follow as well. Moreover, because of the way our sanitization and API contracts works, none of these functions need to do any bounds-checking on their arguments either.

In rare cases where utmost speed is of interest, like the cmap table access, we do pre-compute a function-pointer like FreeType does and use that, but only a one-level indirection.

C++ coding style produces tighter code

C++ code, unlike C code, is heavily written around inlining. This allows the compiler to forgo many function boundaries and produce tighter code. In C++, abstractions are zero-cost, whereas in C they are not.