Memory usage of loading glyph outlines
behdad
March 21 🌷, 2025
Mid-range static TrueType: Roboto-Regular.ttf
Mid-range variable TrueType: RobotoFlex.ttf
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
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:
When processing a COLRv1 PaintComposite, the Skrifa API makes a push_layer(composite_mode) followed later by a pop_layer(). In HarfBuzz paint API, and I assume in most graphics libraries though, the composite_mode is needed during the layer popping. HarfBuzz API has push_group(), later followed up by pop_group(composite_mode). To bridge this API mismatch, hb-fontations layer keeps a stack of the currently-pushed composite modes.
This discrepancy and need for allocation can be obviated by changing the Skrifa API to match HarfBuzz’s. Failing that (if there is a good reason to provide the composite_mode during the layer pushing), I suggest Skrifa pass composite_mode to both push_layer and pop_layer (issue). That would remove this particular vector allocation. UPDATE: When removing this allocation (PR), I did not observe any reduction in allocations; perhaps because the NotoColorEmoji font does not use PaintComposite at all (it uses PaintColrLayers instead).
The second possible allocation in hb-fontations is more intricate, and solely a HarfBuzz pecularity. In the HarfBuzz API, we do not have a way to load glyph outlines without scaling to the current font transform. However, that is needed during a PaintGlyph operation of COLRv1. As such, during a push_clip_glyph call from Skrifa, we first push the inverse of the current font transform onto the renderer, then push the glyph clip, then push back the current font transform. This means that during pop_clip, we need to remember if the clip involved was pushed by a push_clip_box call or a push_clip_glyph, to sandwich the pop_clip with two pop_transform calls on the HarfBuzz side
I cannot immediately think of a way to remedy this, short of passing the extra information during pop_clip, which while free for Skrifa to do, is not palatable to my API design philosophy. Another hacky solution would be for hb-fontations to always emit two extra (identity) transforms in push_clip_box, and always pop three transforms in pop_clip. I dislike this solution even more.
In theory the above can be simplified and the need for the vector removed, by pushing the inverse font transform, push the glyph clip, then pop the transform immediately. This, however, will not work with many client graphics libraries, including the hb-cairo implementation, since Cairo expects the push/pop of clip/transforms to be done in the same order, since both use cairo_save + cairo_restore to manage the drawing context state. Another possibility is to move the pushing and popping of the font transform into the hb-cairo layer, and expect all clients of the HarfBuzz paint API to do the same, using the recently added hb_paint_push_inverse_font_transform / hb_paint_push_font_transform. I may pursue this solution (PR). This brings down the number of allocations from ~24k down to ~20k. Still too high.
Alternative approach, much simpler solution by implementing Skrifa’s fill-glyph callback (PR).
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:
font-file
backend
weight-axis value (or none)
peak process memory size (kb)
total allocation count
total allocation size (bytes).
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.