Optimizing text rendering
28 Jul 2026While getting my text layout system, lexkit, ready for a public release I wanted to make sure performance was as fast as possible. This is a breakdown of the major steps we took to optimize performance and lay out the entire Project Gutenberg text of The Odyssey 6.5 times faster than the Unicode Consortium’s implementation ICU [1].
Table of Contents
Performance and Profiling
Lexkit is a text layout framework capable of handling the following steps of the text rendering pipeline, divided into the stages we use to measure them:
Pre-processing
- Reading a Unicode specification and transforming it into lookup tables.
- Baking font data to a texture.
Per-text operations
- Decoding UTF-8 data to 32-bit codepoints.
- Identifying and splitting paragraphs.
- Performing Bidirectional (BiDi) text handling.
- Shaping text: Converting codepoints to glyph indices.
- Identifying break opportunities at grapheme, word and line levels.
Per-layout operations
- Fitting text into boxes with fixed width, performing hyphenation as appropriate.
- Outputting vertex buffers for rendering.
We are primarily concerned with the last two categories, since the pre-processing steps can be baked offline and don’t incur a runtime cost. We measure this against the de-facto reference Unicode Implementation, ICU [1]. ICU is concerned with performing Unicode operations like BiDi and text breaking and doesn’t have any shaping utilities. To compare them, we use HarfBuzz functions to complete these stages of the pipeline. HarfBuzz [14] is the industry standard for text shaping and is used as part of lexkit as well, so this is a fair comparison.
Measurement setup
To measure performance, we use both CPU profiling tools and instrumenting our code. The Intel VTune Profiler [12] is a common tool that we’ll use to measure CPU utilization, memory bandwidth, L1/L2 cache hit rates and hotspots. We will use the Win32 QueryPerformanceCounter group of functions to compute timestamps and measure the execution times of individual functions.
We measure performance on example03 in the lexkit examples folder (Source), which lays out the entire text of The Odyssey from Project Gutenberg [15] (0.75 MB) at real-time rates. It uses our OpenGL renderer (Source) on a 64-bit Windows 11 machine using an Intel i7-12700K CPU and NVIDIA RTX 4080 GPU.
Memory Allocation
It is extremely difficult to handle arbitrary fonts and lay them out into dynamic dimensions without some degree of runtime memory allocation. Our best bet is to use a performant allocator and avoid using OS utilities like VirtualAlloc for every single minor allocation. For lexkit, we have additional constraints: the allocator should behave well with multiple threads and should accommodate allocations and deallocations at lifetimes that correspond to text rendering.
This is a good choice for a linear allocator, specifically an Arena [9]. Arena allocators work by grabbing a fixed amount of memory, then allocating and freeing blocks using stack-based lifetimes. In other words allocations are last-in, first-out (LIFO). Arenas expose allocation via the following API:
// Arena API
LkArena* lkArenaCreate(void);
void lkArenaDestroy(LkArena* arena);
void* lkArenaPush(LkArena* arena, uint64_t sz, uint64_t aln); // Zero-initialized
void* lkArenaPushNZ(LkArena* arena, uint64_t sz, uint64_t aln); // Uninitialized
uint64_t lkArenaGetPos(LkArena* arena);
void lkArenaRestore(LkArena* arena, uint64_t pos); // Free everything pushed since pos
// Helper macros
#define APush(arena, tp) (tp*)lkArenaPush((arena), sizeof(tp), _Alignof(tp))
#define APushArray(arena, tp, count) (tp*)lkArenaPushArray((arena), sizeof(tp), _Alignof(tp), (count))
#define APushNZ(arena, tp) (tp*)lkArenaPushNZ((arena), sizeof(tp), _Alignof(tp))
#define APushArrayNZ(arena, tp, count) (tp*)lkArenaPushArrayNZ((arena), sizeof(tp), _Alignof(tp), (count))
// Typical usage: scratch is an LkArena
uint64_t frame_pos = lkArenaGetPos(scratch);
LkLine* lines = APushArrayNZ(scratch, LkLine, max_lines);
int32_t* codepoint_orders = APushArrayNZ(scratch, int32_t, line_codepoint_count);
// Use variables in scratch
lkArenaRestore(scratch, frame_pos);
Managing memory ourselves using arenas is far more performant than relying on the CRT. For the below comparison, we replace all arena allocations with malloc, realloc and free CRT calls; the performance benefit is clear. Pure C libraries also typically use the convention that zero-initialized memory is a valid initial state for a struct instance since we don’t have access to constructors. However, if it can be avoided, it helps to clearly denote which objects require zero-initialization and which don’t. Expressly providing allocations that don’t set memory to zero instead of guaranteeing it gave us a small performance win.
We make the following modifications to arenas for our use in a text layout system.
- One issue with the simplest form of arenas is that we have to preallocate the block of memory ahead of time or use a complex scheme every time we want to grow the arena. This may involve either reallocating the entire block of memory, or using a linked list of blocks to accommodate new data. A much easier approach is to use Win32’s
VirtualAlloc[10] to reserve a large block of memory (say 16 GB) up front, and only commit when needed. This exploits the ability of MMUs to supply a large virtual address space up front which is mapped to actual memory only once needed.
#define LK_ARENA_RESERVE (1024llu * 1024llu * 1024llu * 16) // 16 GB
#define LK_ARENA_CHUNK (1024llu * 1024llu) // 1 MB
static void* Reserve(uint64_t size)
{
return VirtualAlloc(NULL, size, MEM_RESERVE, PAGE_NOACCESS);
}
static bool Commit(void* ptr, uint64_t size)
{
return VirtualAlloc(ptr, size, MEM_COMMIT, PAGE_READWRITE) != NULL;
}
LkArena* lkArenaCreate(void)
{
LkArena* arena = calloc(1, sizeof(LkArena));
arena->reserved = LK_ARENA_RESERVE;
arena->committed = 0;
arena->data = Reserve(LK_ARENA_RESERVE);
return arena;
}
static void ArenaCommit(LkArena* arena, uint64_t pos_required)
{
uint64_t pos_target = LK_ARENA_CHUNK * ((pos_required + (LK_ARENA_CHUNK - 1)) / LK_ARENA_CHUNK);
Commit((char*)arena->data + arena->committed, pos_target - arena->committed);
arena->committed = pos_target;
}
static void* ArenaPushRaw(LkArena* arena, uint64_t sz, uint64_t aln)
{
uint64_t pos_aln = ((arena->pos + aln - 1) / aln) * aln;
uint64_t pos_target = pos_aln + sz;
if (pos_target > arena->committed)
ArenaCommit(arena, pos_target);
arena->pos = pos_target;
return (char*)arena->data + pos_aln;
}
- Text rendering involves multiple disjoint data lifetimes:
- Fonts: data that belongs to a specific font family and styles, e.g. a font atlas, per-glyph dimensions, metadata, etc.
- Text: data that belongs to a piece of text, e.g. line break candidate locations, shaped glyph indices, etc.
- Layout: data that belongs to a piece of text that has been organized to be rendered on screen, e.g. actual line break locations.
These lifetimes can be individually managed by separate arenas. For example, we create an arena for a specific piece of text, then as we move through the text rendering pipeline, all allocations that belong to that lifetime (break candidates, BiDi run information, etc.) will simply be pushed onto the arena.
- Following on from that, in multi-threading mode each worker thread has a thread-specific Arena. This allows us to avoid synchronization along the allocator path. To implement this, we have an array of arenas as part of our lexkit context, which is indexed into with the worker thread id.
// One arena per worker thread + one for the main thread
void lkCreateContext(const LkUnicodeData* ud, int num_workers, LkAllocator* alloc, LkContext* o_ctx)
{
...
o_ctx->queue = lkJobQueueCreate(alloc, num_workers);
o_ctx->worker_scratch = AAllocArray(o_ctx->alloc, LkArena*, num_workers + 1);
for (int i = 0; i < num_workers + 1; i++)
o_ctx->worker_scratch[i] = lkArenaCreateFrom(o_ctx->alloc);
}
// Jobs execute on worker threads and index into the arena array with their id
static void ShapeJob(int worker_id, void* data)
{
ShapeJobData* sdata = data;
LkArena* scratch = sdata->ctx->worker_scratch[worker_id + 1];
...
LkGlyph* run_glyphs = APushArrayNZ(scratch, LkGlyph, glyph_count);
...
}
API Design and Caching
It helps to simply do less work rather than doing the same work faster. A text rendering system may have to operate under the following scenarios:
- Rendering entirely pre-determined text in a pre-determined layout, e.g. static text in a game UI, closed captions, etc.
- Rendering entirely pre-determined text in a layout that changes per-frame, e.g. static text in a resizable window.
- Rendering text that changes per-frame in a layout that may or may not change per-frame, e.g. editable text in an editor.
Lexkit doesn’t cache any data by default, but exposes an API that allows applications to manage their own caches:
// Context: Cache this as long as we make calls to lexkit.
LkContext ctx;
lkCreateContext(&g_lk_unicode_data, num_workers, NULL, &ctx);
// Font: Cache this as long as font remains relevant
LkFont font;
lkCreateFont(&ctx, "C:/Windows/Fonts/arial.ttf", 20, &font);
// Per-text operations: UTF-8 decode, BiDi, shaping, break computation, etc.
// Cache this object if the text remains unchanged.
LkText text;
lkCreateText(&ctx, &font, utf8, utf8_len, &text);
// Per-layout operations: line fitting, reordering, positioning, etc.
// Cache this object if the text AND rect it's laid out into remains unchanged.
LkVertexDescriptor_Text quads[MAX_QUADS];
int32_t quad_count = 0;
lkLayoutText(&ctx, &font, &text, width, height, scroll_y, MAX_QUADS, quads, &quad_count);
Efficient UTF-8 decoding
Lexkit accepts text in the UTF-8 encoding, and thankfully the process of decoding UTF-8 to 32-bit Unicode codepoints is well-covered with a variety of approaches [8]. We use the simple lookup table approach originally from Bjoern Höhrmann [7].
// "Flexible and Economical UTF-8 Decoder" by Bjoern Hoehrmann
#define UTF8_ACCEPT 0
#define UTF8_REJECT 1
static const uint8_t utf8d[] = {
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, // 00..1f
...
0xb,0x6,0x6,0x6,0x5,0x8,0x8,0x8,0x8,0x8,0x8,0x8,0x8,0x8,0x8,0x8, // f0..ff
0x0,0x1,0x2,0x3,0x5,0x8,0x7,0x1,0x1,0x1,0x4,0x6,0x1,0x1,0x1,0x1, // s0..s0
...
1,3,1,1,1,1,1,3,1,3,1,1,1,1,1,1,1,3,1,1,1,1,1,1,1,1,1,1,1,1,1,1, // s7..s8
};
static inline uint32_t Utf8DecodeStep(uint32_t* state, uint32_t* codep, uint32_t byte)
{
uint32_t type = utf8d[byte];
*codep = (*state != UTF8_ACCEPT) ?
(byte & 0x3fu) | (*codep << 6) :
(0xff >> type) & (byte);
*state = utf8d[256 + *state*16 + type];
return *state;
}
Additionally, we have a fast path for ASCII characters that can decode 8 codepoints at the same time, shown below. These two optimizations together bring The Odyssey decode times down from 19 ms to 0.8 ms.
static uint64_t Utf8Decode(const uint8_t* s, uint64_t n, uint32_t* o_codepoints)
{
...
while (i < n)
{
if (state == UTF8_ACCEPT)
{
// Fast path for ASCII characters
while (i + 8 <= n)
{
uint64_t chunk;
memcpy(&chunk, s + i, 8);
if (chunk & 0x8080808080808080llu)
break;
if (o_codepoints)
for (int k = 0; k < 8; k++)
o_codepoints[count + k] = s[i + k];
count += 8;
i += 8;
}
if (i >= n)
break;
start = i;
}
// Lookup table that supports longer codepoints
Utf8DecodeStep(&state, &cp, s[i]);
...
}
return count;
}
Fast Paths
On the subject of fast paths, it is extremely beneficial to identify the most common case for each stage of the pipeline and have a specialized code path to deal with it. While this can be dependent on the language, locale and script you expect to deal with most commonly, at the absolute minimum including special code paths for the simplest case is almost always a huge win. All of these results are measured using single-threaded mode.
| Pipeline Stage | Fast Path | Before | After |
|---|---|---|---|
| UTF-8 Decoding | Load 8 consecutive bytes in a 64-bit integer, test for ASCII and copy over if test passed | 19 ms | 0.8 ms |
| BiDi Classes | Look up bracket pairs only for codepoints of class Other Neutral. Every other class is never a bracket. |
31.9 ms | 5.0 ms |
| BiDi Level Runs | Skip the majority of rules if the paragraph contains no RTL or numeric characters | 14.5 ms | 2.5 ms |
| Break Analysis | Between two ASCII letters there is never a line, word or grapheme break. | 24.2 ms | 20.7 ms |
| Reordering & Positioning | Emit quads only for lines inside the viewport and stop at the first line below it | 3.1 ms per frame | 0.02 ms per frame |
Data structures: The Two-Step table
Most Unicode algorithms (Line breaking [5] [6], BiDi handling [4], etc.) involve classifying codepoints into Unicode classes. This is given by specification files [3] that map ranges of codepoints to classes. Each specification file contains these mappings for a specific property, for example here’s what some of these files look like:
# LineBreak-16.0.0.txt
# @missing: 0000..10FFFF; XX
0020 ; SP # Zs SPACE
0028 ; OP # Ps LEFT PARENTHESIS
0029 ; CP # Pe RIGHT PARENTHESIS
002D ; HY # Pd HYPHEN-MINUS
0041..005A ; AL # Lu [26] LATIN CAPITAL LETTER A..LATIN CAPITAL LETTER Z
...
05D0..05EA ; HL # Lo [27] HEBREW LETTER ALEF..HEBREW LETTER TAV
...
4E00..9FFF ; ID # Lo [20992] CJK UNIFIED IDEOGRAPH-4E00..CJK UNIFIED IDEOGRAPH-9FFF
Of course, looking up all these codepoints naively results in a totally unacceptable runtime. The typical strategy is to compress the mapping into a data structure called a Two-Step table [2] [16]. To construct a Two-Step table, we first divide the overall codepoint range, $U = 1,114,112$ into blocks of size $N=256$ resulting in $K=\lceil U/N\rceil =4352 $ blocks. The specification contains a mapping to Unicode classes for these $N$ consecutive codepoints. If we look at all $K$ sequences of $N$ consecutive mappings, we note that there are $M$ unique sequences with $M \ll K$. This is because there are large sequences of codepoints that belong to the same class.
Two-Step tables exploit this property. A Two-Step table consists of two lookup tables. The upper level table translates codepoints into indices of the second table. The second table contains $M$ unique sequences of mappings described above. For Unicode classes, this is far more runtime and memory efficient than an off-the-shelf hashmap.
We can see the impact of Two-Step tables on lookup performance for our Odyssey example by cumulatively switching each individual property from a linear lookup to a Two-Step table, as the chart below shows:
Baking Unicode Specification
Lexkit exposes the ability to load arbitrary specification files, essentially future-proofing against new Unicode versions. This allows us to build the Two-Step tables for any version of Unicode at runtime.
LkUnicodeSpecInfo spec = {0};
spec.str_path_lb = "ucd/LineBreak.txt";
spec.str_path_wb = "ucd/auxiliary/WordBreakProperty.txt";
spec.str_path_gb = "ucd/auxiliary/GraphemeBreakProperty.txt";
spec.str_path_gc = "ucd/UnicodeData.txt";
spec.str_path_eaw = "ucd/EastAsianWidth.txt";
spec.str_path_incb = "ucd/DerivedCoreProperties.txt";
spec.str_path_ep = "ucd/emoji/emoji-data.txt";
spec.str_path_bidi = "ucd/extracted/DerivedBidiClass.txt";
spec.str_path_bidipb = "ucd/BidiBrackets.txt";
LkUnicodeData ud = {0};
if (lkTryLoadUnicodeDataFromSpec(arena, &spec, &ud))
{
LkContext ctx;
lkCreateContext(&ud, num_workers, NULL, &ctx);
}
Realistically though, it’s preferable to select the latest Unicode version and build against that. To avoid paying the Two-Step construction time at startup, we bake all the data to a header. When I started work on lexkit, this was Unicode 16, so adding it as a header here skips this cost entirely while increasing the source code size by ~650 KB, and the linked binary by 1 MB.
SIMD instructions
Using the microarchitecture exploration report from Intel VTune, we observed that one of the hotspots for our Odyssey example was in performing Two-Step table lookups over the text data. VTune indicates that the retirement rate is high:
which indicates that reducing the number of instructions is a natural way to proceed. These lookups are independent of each other and so can be easily split up to multithreaded jobs. To improve single-threaded performance (which also helps with multithreaded performance), we can use SIMD instructions instead. Here’s an example with AVX2 [11]:
#define BLOCK_P2 8
#define BLOCK_SIZE (1 << BLOCK_P2)
#define BLOCK_MASK ((1 << BLOCK_P2) - 1)
int32_t LkTwoStepLookup(LkTwoStep* ts, uint32_t ch)
{
uint32_t block0_i = ch / BLOCK_SIZE;
uint32_t block1_i = ts->block0[block0_i] * BLOCK_SIZE + (ch % BLOCK_SIZE);
return ts->block1[block1_i];
}
__m128i LkTwoStepLookupAvx2(LkTwoStep* ts, __m128i ch)
{
const __m128i block_mask = _mm_set1_epi32(BLOCK_MASK);
__m128i block0_i = _mm_srli_epi32(ch, BLOCK_P2);
__m128i block1_idx = _mm_i32gather_epi32(ts->block0, block0_i, 4);
__m128i block1_start = _mm_slli_epi32(block1_idx, BLOCK_P2);
__m128i block1_offset = _mm_and_epi32(ch, block_mask);
__m128i block1_i = _mm_add_epi32(block1_start, block1_offset);
__m128i lk_result = _mm_i32gather_epi32(ts->block1, block1_i, 4);
return lk_result;
}
static void GetGlyphAtIndexAvx2(const uint32_t* codepoints, int32_t len_codepoints, int32_t idx, const LkUnicodeData* ud, Glyph* o_glyphs)
{
...
// Get codepoint
__m128i idx_vec = _mm_loadu_epi32(idx_arr);
__m128i codepoint_vec = _mm_i32gather_epi32(codepoints, idx_vec, 4);
__m128i lbc_vec = LkTwoStepLookupAvx2(ud->ts_lb, codepoint_vec);
__m128i wbc_vec = LkTwoStepLookupAvx2(ud->ts_wb, codepoint_vec);
__m128i gbc_vec = LkTwoStepLookupAvx2(ud->ts_gb, codepoint_vec);
__m128i gc_vec = LkTwoStepLookupAvx2(ud->ts_gc, codepoint_vec);
__m128i eaw_vec = LkTwoStepLookupAvx2(ud->ts_eaw, codepoint_vec);
__m128i incb_vec = LkTwoStepLookupAvx2(ud->ts_incb, codepoint_vec);
__m128i extended_pictographic_vec = LkTwoStepLookupAvx2(ud->ts_ep, codepoint_vec);
...
}
This improves performance significantly, reducing break computations in single-threaded mode by 12.7 ms.
Better Algorithms for BiDi reversals
The BiDi reordering step of the pipeline involves successively reversing large sections of text. See my description in the previous article here A trip through the text rendering pipeline. The Unicode standard [4] allows for $K=125$ maximum levels of BiDi nestedness, so this is $O(NK)$ by default, far above what’s ideal in a real-time setting. By preprocessing the BiDi levels, we can get a far faster algorithm where each codepoint is moved at most once:
This has a powerful effect on The Odyssey example’s performance, reducing it by 32 ms per frame, or 4.3 times faster than the naive BiDi reordering stage.
Multithreading
As we explained in the previous article, text layout can be divided into a number of hierarchical steps: Paragraphs, BiDi runs, lines, codepoints, etc. As a result, converting optimized single-threaded code into multithreaded code quickly becomes a no-brainer. By default, lexkit operates in single-threaded mode; however, you can specify the num_workers parameter at context creation time to create a multithreaded context.
We use a lockless job queue in lexkit. This means that there are no mutexes or locks used to synchronize threads, in favor of a simplified architecture that only uses atomics and semaphores. The benefit of this format is that it’s guaranteed to make progress without the possibility of deadlocks. Here’s what the job API looks like:
typedef void (*LkJobFunc)(int worker_id, void* data);
typedef struct
{
LkJobFunc func;
void* data;
} LkJob;
LkJobQueue* lkJobQueueCreate(LkAllocator* alloc, int num_workers);
void lkJobQueuePush(LkJobQueue* queue, LkJob job);
void lkJobQueueWait(LkJobQueue* queue);
void lkJobQueueDestroy(LkJobQueue* queue);
For example, this is how BiDi classes are computed for every codepoint:
typedef struct
{
int i;
int sz;
const LkUnicodeData* ud;
const uint32_t* codepoints;
BidiUnit* o_units;
} ComputeBidiUnitData;
static void ComputeBidiUnitJob(int worker_id, void* data)
{
ComputeBidiUnitData* cdata = data;
for (int i = cdata->i; i < cdata->i + cdata->sz; i++)
{
cdata->o_units[i] = BidiUnitCreate(cdata->codepoints[i], cdata->ud);
}
}
void lkComputeBidiUnits(
LkContext* ctx,
LkArena* arena,
const uint32_t* codepoints,
int32_t len_codepoints,
BidiUnit** o_units)
{
...
int32_t work_chunk = len_codepoints / ((ctx->queue->num_workers + 1) * 4);
if (work_chunk < 16384) work_chunk = 16384;
int32_t num_chunks = (len_codepoints / work_chunk) + (len_codepoints % work_chunk > 0);
ComputeBidiUnitData* job_data = APushArrayNZ(scratch, ComputeBidiUnitData, num_chunks);
int chunk_i = 0;
for (int32_t i = 0; i < len_codepoints; i += work_chunk)
{
job_data[chunk_i].i = i;
job_data[chunk_i].sz = (i + work_chunk > len_codepoints) ? (len_codepoints - i) : work_chunk;
job_data[chunk_i].ud = ctx->ud;
job_data[chunk_i].codepoints = codepoints;
job_data[chunk_i].o_units = *o_units;
LkJob job;
job.func = ComputeBidiUnitJob;
job.data = &job_data[chunk_i];
chunk_i++;
lkJobQueuePush(ctx->queue, job);
}
lkJobQueueWait(ctx->queue);
...
}
Splitting up the work among jobs is simple. For trivially parallel tasks like looking up Unicode classes from codepoints, this is a simple matter of dividing the entire text corpus into blocks then submitting the ranges into the job queue. For tasks that involve variable output, such as converting a list of codepoints into BiDi runs, we need two passes. The first outputs the results for each job to a worker-local arena. Then we perform a prefix sum on the main thread and dispatch the second stage that copies the results to a shared central array.
Of course, multithreading represents the biggest potential for performance improvement. Our single-threaded performance is already superior to Unicode’s reference implementation which is entirely single-threaded by design. At num_workers >= 1, we handily beat them:
We notice that num_workers over 8 does not help with performance further on this text corpus for our machine. The Intel i7-12700K has 8 Performance cores and 4 Efficiency cores, which support 20 threads in total. At each multithreaded stage, the total input data is divided into chunks which are assigned to jobs. The number of workers and chunks is related by the formula: chunk_size = max(len / ((num_workers + 1) * 4), 16384). As chunk size increases, the number of jobs decreases and work done as part of an individual job increases. There’s a tradeoff between the overhead associated with a job and the parallel speedup offered by multiple workers [13], so increasing num_workers beyond a certain threshold will likely lead to performance degradation rather than improvement. Our formula for chunk size limits the maximum number of jobs so instead of degradation we see stagnation at higher worker counts.
Conclusions and Future Work
This covers the major steps along the way of getting lexkit to where it is today, but there were many smaller steps that helped bring runtimes down and many things that could still be improved. I encourage readers to take a look at the project on GitHub.
Here are some things that I would like to look at in the future, given time:
- SIMD instructions could be extended to far more areas than just Two-Step table lookup. It’s likely that Line Breaking and the Bidirectional Text Algorithm could also be accelerated by vectorized instructions. Currently codepoints and their associated data are stored in an Array-of-Structs (AoS) format. It’s likely that moving to a Structure-of-Arrays format would be necessary for further progress in this area.
- This optimization process was entirely contained to the CPU since the GPU side of rendering quads offers little opportunity for significant performance wins. However, it’s clear that large sections of the text rendering pipeline could be ported to compute shaders. As far as I know this isn’t done by any major text layout library and would be interesting to pursue.
References
[1] Unicode Consortium. ICU - International Components for Unicode
[2] Unicode Consortium. The Unicode Standard, Core Specification, Chapter 5: Implementation Guidelines
[3] Unicode Consortium. Unicode Standard Annex #44: Unicode Character Database
[4] Unicode Consortium. Unicode Standard Annex #9: Unicode Bidirectional Algorithm
[5] Unicode Consortium. Unicode Standard Annex #14: Unicode Line Breaking Algorithm
[6] Unicode Consortium. Unicode Standard Annex #29: Unicode Text Segmentation
[7] Bjoern Hoehrmann, 2010. Flexible and Economical UTF-8 Decoder
[8] John Keiser and Daniel Lemire, 2021. Validating UTF-8 In Less Than One Instruction Per Byte
[9] Ryan Fleury, 2022. Untangling Lifetimes: The Arena Allocator
[10] Microsoft. VirtualAlloc function (memoryapi.h)
[11] Intel. Intel Intrinsics Guide
[12] Intel. Intel VTune Profiler User Guide
[13] Gene M. Amdahl, 1967. Validity of the Single Processor Approach to Achieving Large Scale Computing Capabilities
[14] HarfBuzz Manual
[15] Homer, translated by Alexander Pope. The Odyssey, Project Gutenberg eBook #3160
[16] Peter Kankowski, 2006. Two-stage tables for storing Unicode character properties








