Memory usage of loading glyph outlines

behdad
March 21 🌷, 2025

Introduction

Methodology

Tested versions

Test harness

Tested fonts

Example: MutatorSans.ttf

valgrind

malloc-stats

time

Results

Mid-range static TrueType: Roboto-Regular.ttf

Mid-range variable TrueType: RobotoFlex.ttf

No variations specified

Variations specified at default location

Variations specified at non-default location

Mid-range avar2 variable TrueType: RobotoA2-avar2-VF.ttf

Heavy variable TrueType: NotoSansCJK-VF.ttf.ttc

Heavy variable CFF2: NotoSansCJK-VF.otf.ttc

Emoji COLRv1 color font: NotoColorEmoji-Regular.ttf

Conclusions

Appendix: Reproducing results

Introduction

We investigate the memory consumption of extracting glyph outlines across a range of font technologies, using three libraries: HarfBuzz, FreeType, and Skrifa.

Methodology

We use a custom test harness to load the font using HarfBuzz, then proceed to load outlines for all glyphs in the font using three backends: hb-ot (native HarfBuzz), hb-ft (using FreeType), and hb-fontations (using Skrifa).

We then measure the number of memory allocations (malloc and realloc) as well as total size of such allocations, using two tools: the valgrind memcheck tool and malloc-stats. Peak process memory size is measured using /usr/bin/time, which includes the size of the shared-libraries as well as the accessed font file tables, since this is not showing just allocated memory, but also memory-mapped regions accessed.

Note that we invoke /usr/bin/time instead of just time, because the latter invokes bash’s internal timing tool, which does not report peak process memory usage.

To minimize noise, I have compiled HarfBuzz without the glib integration layer, since glib does some unconditional library initialization that involves memory allocations that are of no use to us and pollutes our numbers. Neither Chrome nor Android use the glib integration of HarfBuzz and as such are not affected.

Tested versions

HarfBuzz main branch from March 21 was tested. This unreleased version contains major work towards alloc-free drawing of all font formats.

Skrifa 0.29.1 was tested. This version includes several optimizations in the gvar drawing code.

Test harness

A test harness called hb-draw-all was developed, that simply loads a font file with HarfBuzz, sets the desired font-functions backend, and optionally sets a variable wght axis, then proceeds to load outlines of all glyphs in the font. The harness uses no external dependencies other than HarfBuzz, as to minimize measurement noise.

For testing color-font performance, we use a similar harness that calls into HarfBuzz paint API instead of draw: hb-paint-all.

Tested fonts

For baseline, we test a small static TrueType font, MutatorSans.ttf with just 50 glyphs.

For mid-range, we test Roboto-Regular.ttf with 1294 glyphs. To test variable fonts, we go with the heavyweight RobotoFlex.ttf with 948 glyphs and a whopping 81 masters. We contrast that with the avar2 version of the same design, RobotoA2-avar2-VF.ttf obtained from here, codenamed Roboto Delta.

For heavy-weight fonts, we test the full variable NotoSansCJK-VF with 65,535 glyphs, in its two flavors: CFF2 and glyf/gvar.

For the COLRv1 color-font technology, we test NotoColorEmoji-Regular.ttf with 39,793 glyphs.

Example: MutatorSans.ttf

Let’s look into a run of those three tools over the ot backend with MutatorSans.ttf. Showing only relevant lines of the output:

valgrind

The trusty valgrind memcheck tool, while not a heap-profiler, does report heap allocation statistics. There exists the valgrind massif tool, which is a heap-profiler and can be used for further inspection. That tool, however, was not necessary for this experiment.

$ valgrind build/test/draw/hb-draw-all MutatorSans.ttf ot

==3603356== total heap usage: 34 allocs, 34 frees, 5,036 bytes allocated

We see a total of 34 allocations. Since MutatorSans.ttf includes 50 glyphs, we can already deduce that glyph outline extraction is using less than one allocation per glyph. If we establish that the 34 allocations are, in fact, fairly independent of the number of glyphs loaded, we would call the results alloc-free drawing.

malloc-stats

The malloc-stats tool reports similar aggregate values as valgrind. The use of both is done for cross-checking. Where malloc-stats shows an edge is however, it also reports where in the code each allocation is made. See the README file for details.

$ LD_PRELOAD=$PWD/malloc-stats.so build/test/draw/hb-draw-all MutatorSans.ttf ot

TOTAL MALLOC REALLOC

num size num size num size

34 5,036 27 2,968 7 2,068 (total)

Here we see the same total number of allocations (34), for the same total number of bytes (5,036). Of which, 27 sare initial allocations (malloc) and 7 are reallocations (realloc).

time

While geared time measurement, the time binary reports peak process memory usage, including memory-mapped files. This only includes the regions of mapped files that are actually accessed by the process.

$ /usr/bin/time build/test/draw/hb-draw-all MutatorSans.ttf ot

0.00user 0.00system 0:00.00elapsed 100%CPU (0avgtext+0avgdata 3196maxresident)k

In this case, we see that the process size was ~3.2MB. For context, the HarfBuzz library I am testing with has a .text size of ~1.8MB. Inspecting the process through /proc/{PID}/maps file shows that other libraries loaded include libbrotlicommon, libbrotlidec, libz, libpng16, libbz2, libfreetype (all pulled in by FreeType), and libm, libc, libgcc_s, as well as the linker ld-linux-x86-64. Note that the .text section of all shared-libraries mapped are always accessed because of the runtime symbol relocation.

The MutatorSans.ttf font file is a mere 17kb. As such, we are going to use this ~3.2MB as the baseline against which we will measure peak memory usage of the other tests.

Note that because of address-space randomization and other experiment pollution, the peak process size reported by time varies by around ~300kb across runs.

Moreover, Android loads all system libraries related to text-rendering as well as system fonts during system boot (zygot process?) and Chrome forks renderers after the same initialization. As such, the baseline peak memory usage will be reduced in those systems since library relocations are already done and the memory for those will not be modified again.

Results

In this section we summarize the total number of allocations, total size of allocations, and peak process memory usage, for hb-ot, hb-ft, and hb-fontations font backends, for a variety of font technologies.

Mid-range static TrueType: Roboto-Regular.ttf

Number of glyphs: 1,294. File size: 172kb.

Backend

Alloc count

Alloc size

Process size

Alloc-free

ot

31

7,812

3.2MB

Yes

ft

804

58,033

3.5MB

Unlikely

fontations

16

3,792

3.3MB

Yes

The HarfBuzz-native ot backend performs more one-time allocations than fontations, but both remain in a low-enough range to be of no concern. The extra ot allocations come from 1. allocating hb_blob_t objects for various loaded font tables loaded lazily in a thread-safe manner, as well as allocating scratch-pad working areas for loading glyphs. The fontations implementation in Skrifa on the other hand, uses a 4kb stack allocation for the outline-loading purpose, and falls back to allocation if that is not enough.

Mid-range variable TrueType: RobotoFlex.ttf

Number of glyphs: 948. File size: 1,775kb. Number of masters: 81.

No variations specified

Without specifying any variations, fetching the default instance:

Backend

Alloc count

Alloc size

Process size

Alloc-free

ot

34

6,600

3.2MB

Yes

ft

795

42,048

3.5MB

Unlikely

fontations

17

3,840

3.3MB

Yes

Note that when accessing the default instance of the variable font, peak process size has not increased, suggesting that neither backend accesses the font’s large gvar table at all.

Variations specified at default location

By specifying wght=400, which happens to be the default location for this axis, we can measure whether various libraries optimize such cases or not:

Backend

Alloc count

Alloc size

Process size

Alloc-free

ot

38

6,800

4.7MB

Yes

ft

25,882

3,987,583

5.3MB

No

fontations

21

4,400

3.5MB

Yes

FreeType goes crazy with allocations when variations are requested, even if at the default instance.

Skrifa seems to do more allocations (PR), but otherwise not touch the gvar table.

HarfBuzz seems to access the gvar table currently. I have an unreleased PR to fix that. Skrifa has the cleanest slate.

Variations specified at non-default location

Specifying wght=500.

Backend

Alloc count

Alloc size

Process size

Alloc-free

ot

52

12,308

4.7MB

Yes

ft

30,616

4,537,689

5.3MB

No

fontations

22

4,066

4.8MB

Yes

FreeType’s allocations go even higher, probably because of processing the variations for the weight master. We see Skrifa also accessing the gvar table this time.

Mid-range avar2 variable TrueType: RobotoA2-avar2-VF.ttf

The avar2 technology allows for drastically reducing the number of the masters in a parametric font design like RobotoFlex while preserving the same functionality. Note the file size reduction compared to RobotoFlex, despite the higher number of glyphs: 522kb versus 1,775kb.

Number of glyphs: 1,141. File size: 522kb. Tested at wght=500.

Backend

Alloc count

Alloc size

Process size

Alloc-free

ot

63

15,420

3.6MB

Yes

ft

45,540

6,013,783

4.2MB

No

fontations

24

4,622

3.8MB

Yes

The process size goes down across the board, as the font is smaller. FreeType behavior, however, is 50% more egregious than the previous test, for reasons not clear to me.

Heavy variable TrueType: NotoSansCJK-VF.ttf.ttc

Number of glyphs: 65,535. File size: 37mb. Tested at wght=500.

Backend

Alloc count

Alloc size

Process size

Alloc-free

ot

86

104,916

38MB

Yes

ft

728,351

550,916,407

39MB

No

fontations

20,221

105,247,065

38MB

No

This is where Skrifa’s fixed-size stack-based 4k work-area allocation shows. When complex glyphs of the CJK font do not fit in the work area, a heap allocation is made, making Skrifa strictly not an alloc-free implementation for complex designs.

Update: The Skrifa work area was enlarged to 16kb, which would remove most Skrifa allocations for this font. (PR) Here is an updated table with Skrifa 0.29.2:

Backend

Alloc count

Alloc size

Process size

Alloc-free

ot

86

104,916

38MB

Yes

ft

728,351

550,916,407

39MB

No

fontations

28

140,135

38MB

Yes

Heavy variable CFF2: NotoSansCJK-VF.otf.ttc

Number of glyphs: 65,535. File size: 32mb. Tested at wght=500.

Backend

Alloc count

Alloc size

Process size

Alloc-free

ot

75

10,188

32MB

Yes

ft

262,366

577,221,802

33MB

No

fontations

20

3,896

32MB

Yes

Since CFF2 outlines can be loaded in one go, Skrifa is back to behaving very well. FreeType’s behavior has improved, though it still is really bad.

Emoji COLRv1 color font: NotoColorEmoji-Regular.ttf

Number of glyphs: 39,793. File size: 24mb. These numbers need explanation: First, the font only covers 1,492 Unicode emoji characters. The vast majority of the glyphs are shape parts, not color glyphs per se. Moreover, the font also includes an SVG table, which is redundant and unused by COLRv1 renderers. Of the 24mb, about 20mb is the unused SVG table. Update: I rerun the experiment with Noto-COLRv1.ttf, that does not have the SVG table, and reached at the exact same numbers.

Backend

Alloc count

Alloc size

Process size

Alloc-free

ot

27

3,464

4.2MB

Yes

ft

111

841,603

8.7MB

Yes

fontations

23,933

611,996

4.3MB

No

The large number of allocations by the fontations backends are two-fold: those coming from Skrifa itself, and the ones coming from the hb-fontations integration layer.

As for Skrifa, it allocates a vector for the color-line of each gradient. This can be easily mitigated by using a small-vector implementation to remove the allocation for most common cases. (PR)

There are two vectors allocated by the hb-fontations layer per paint call, for the following reasons:

Update: With all the PRs in (Skrifa 0.29.2) and HarfBuzz counterparts:

Backend

Alloc count

Alloc size

Process size

Alloc-free

ot

27

3,464

4.2MB

Yes

ft

111

841,603

8.7MB

Yes

fontations

16

3,864

4.3MB

Yes

Conclusions

FreeType is malloc-heavy across a range of font technologies. Skrifa 0.29.2 is lean with malloc for all font technologies as long as the font complexity remains in check. All fonts we test fit that criteria.. HarfBuzz is lean on malloc across the board, though behind Skrifa generally when both perform alloc-free drawing.

This experiment suggests that by switching to Skrifa from FreeType, Android can significantly reduce malloc pressure in the text rendering subsystem.

One caveat of note: glyph outline extraction is heavily cached, and the memory and time cost is typically dwarfed by those of the actual shape rasterization. We have not measured that.

Another minor caveat is that HarfBuzz is highly optimized in terms of allocations for single-thread usage. This is not a problem in practice since all major systems we know of use HarfBuzz from a single thread. Only when two threads are performing the same draw or paint operation with the same font technology at the same time, one of the threads would have to make some allocations for the duration of the operation. This is very unlikely.

Appendix: Reproducing results

The results can be reproduced using this gist. Only RobotoFlex.ttf is shown. Output looks like:

/home/behdad/hb/RobotoFlex.ttf;ot;;3188;32;6528

/home/behdad/hb/RobotoFlex.ttf;ot;400;4744;36;6728

/home/behdad/hb/RobotoFlex.ttf;ot;500;4692;50;12236

/home/behdad/hb/RobotoFlex.ttf;ft;;3536;794;41984

/home/behdad/hb/RobotoFlex.ttf;ft;400;5160;25881;3987519

/home/behdad/hb/RobotoFlex.ttf;ft;500;5372;30615;4537625

/home/behdad/hb/RobotoFlex.ttf;fontations;;3424;16;3776

/home/behdad/hb/RobotoFlex.ttf;fontations;400;3160;20;3976

/home/behdad/hb/RobotoFlex.ttf;fontations;500;4904;21;4002

The semicolon-separated fields respectively are:

Since this uses a fresh build of HarfBuzz (mine had experimental APIs enabled, which make hb_face_t slightly larger), the actual numbers are slightly different from my report, but meaningfully in the same ballpark.