Bali, Indonesia
Insights

Measuring Memory Footprint and Write Latency in Embedded Database Transparent Page Encryption

October 15, 2026
0:00
Embedded databases operating on edge gateways—ranging from industrial telemetry collectors to connected appliance controllers—frequently implement data-at-rest encryption at the page level rather than relying on full-disk encryption (FDE) via dm-crypt or filesystem-level encryption such as fscrypt. Page-level transparent encryption, commonly deployed via custom Virtual File System (VFS) layers (such as SQLCipher, SQLite Encryption Extension, or bespoke embedded B-tree storage engines), operates entirely in userspace or at the boundary between the database buffer pool and the operating system page cache. In a standard non-encrypted architecture, the database maintains a buffer pool of fixed-size pages (typically 4096 bytes). When a transaction modifies a page, the engine marks the memory frame as dirty. During a checkpoint or when an eviction policy selects the page, the storage engine passes the in-memory page pointer directly to the write() or pwrite64() system call. The operating system copies the payload into the kernel page cache, which is subsequently flushed to non-volatile storage via fdatasync(). Transparent page encryption alters this pipeline by interposing a cryptographic transformation step before the page reaches the operating system. When using AES-256-XTS (XEX-based tweaked-codebook mode with ciphertext stealing, defined in IEEE 1619), each database page is treated as an independent cryptographic unit. The page number within the database file serves as the 128-bit initialization vector or tweak value T. Because AES-256-XTS requires two distinct 256-bit keys—one to generate the tweak encryption (K_2) and one for the primary block cipher (K_1)—the cipher engine must execute two block cipher operations per 16-byte block, preceded by the tweak computation: T_0 = AES_Encrypt(K_2, Page_Number) T_j = (T_{j-1} << 1) ^ (T_{j-1}[127] ? 0x87 : 0x00) C_j = AES_Encrypt(K_1, P_j ^ T_j) ^ T_j
TEXT
While AES-256-XTS prevents replay attacks between pages and ensures that identical plaintext written to different physical database offsets produces different ciphertexts, its execution inside an embedded buffer pool introduces severe structural memory pinning and writeback serialization overheads.


|                     Database Buffer Pool                    |
|  [Page 1: Plaintext]  [Page 2: Plaintext]  [Page 3: Dirty]  |
+-------------------------------------------------------------+
                                                        | (Needs Flush)
                                                        v
+-------------------------------------------------------------+
|                 Intermediate Bounce Buffer                  |
|  Allocated: 4096 Bytes (Double Buffering)                   |
|  Operation: AES-256-XTS Tweak + Encryption                  |
+-------------------------------------------------------------+
                                                        |
                                                        v `pwrite64()`
+-------------------------------------------------------------+
|                   Linux Kernel Page Cache                   |
|  Dirty Page Allocated -> Block I/O Layer -> Flash Storage   |
+-------------------------------------------------------------+
In embedded Linux platforms provisioned with 256 MB to 512 MB of total system RAM, memory is tightly partitioned among kernel slabs, network socket buffers, application runtime memory, and the database buffer pool. When an embedded engine encrypts a page, it cannot encrypt the page in-place within the active database buffer pool. Doing so would expose the dirty page to concurrent read transactions, causing dirty reads of partially or fully enciphered blocks, or forcing read transactions to wait until the writeback and a subsequent decryption step complete. To maintain transaction isolation and non-blocking reads during disk flush routines, the engine must employ double-buffering by allocating an intermediate "bounce buffer." For every dirty page written to disk, the database must:
  1. Lock the plaintext page frame in the buffer pool (shared lock).
  2. Allocate or acquire a 4096-byte memory buffer from an auxiliary pool.
  3. Copy or transform the plaintext bytes into the bounce buffer through the AES-XTS cipher.
  4. Release the shared lock on the plaintext page frame.
  5. Issue the pwrite64() system call pointing to the bounce buffer.
  6. Hold the bounce buffer allocation until the operating system confirms completion of the synchronous write or until asynchronous write queues drain.
Under high-frequency ingestion workloads, where multiple worker threads flush dirty pages concurrently, memory overhead doubles for all active writes. If a flush sweep involves 1,024 pages (4 MB of data), the system suddenly requires 4 MB of database buffer pool allocation, 4 MB of transient userspace bounce buffer memory, and 4 MB of Linux kernel page cache memory simultaneously—a 300% physical memory footprint amplification for in-flight writes. When memory allocators cannot satisfy these transient demands via thread-local caches, the system invokes kernel page allocation routines (alloc_pages), triggering memory reclaim paths. This behavior is verifiable by inspecting kernel memory pressure and direct reclaim counters:
BASH
# Monitor page allocation stalls and direct reclaim activation
vmstat -s | grep -E 'pgpgin|pgpgout|allocstall|kswapd'
If the application utilizes dynamic memory allocation (malloc()) for each bounce buffer instead of a static ring-buffer pool, heap fragmentation rapidly degrades performance. The runtime heap manager allocates non-contiguous chunks that cannot be easily returned to the kernel via brk() or madvise(MADV_DONTNEED), progressively driving the Resident Set Size (RSS) upward until the system approaches its memory limits.
Database persistence guarantees rely on Write-Ahead Logging (WAL) or roll-forward journals to enforce atomicity and durability. In a WAL configuration, transaction commits require writing log frames sequentially and executing an fsync() or fdatasync() system call to guarantee that the journal pages persist to non-volatile medium before confirming the commit to the application. When transparent page encryption is active, the WAL frames themselves must also be encrypted. This places the AES-256-XTS cipher directly on the critical path of every disk-synchronous commit. The latency of this path (L_{\text{commit}}) is defined as:
TEXT
L_commit = T_lock + T_tweak + T_encrypt + T_syscall + T_driver + T_flash
On embedded system-on-chips (SoCs) based on older ARM Cortex-A7 or Cortex-A53 cores lacking the ARMv8 Cryptography Extensions (which provide hardware acceleration instructions like AESE, AESD, AESMC, and AESIMC), the cipher transformation must execute purely in software (e.g., using optimized assembly routines from OpenSSL, mbedTLS, or kernel AF_ALG). A 4096-byte page encrypted with AES-256-XTS requires processing 256 individual 16-byte blocks. On an ARM Cortex-A7 clocked at 800 MHz without crypto extensions, pure software AES-256-XTS processing consumes approximately 18.5 to 22.0 cycles per byte. The cryptographic transformation of a single 4096-byte page requires:
TEXT
Cycles = 4096 * 20.25 = 82,944 cycles
Latency = 82,944 / (800 * 10^6) = 103.68 microseconds
While 104 microseconds appears negligible for a single page, batch transactions flushing 64 pages sequentially across a single WAL commit incur a direct CPU serialization delay of approximately 6.6 milliseconds before the I/O request reaches the Linux block layer. This CPU delay directly stalls worker threads, causing locks on the write-ahead log to be held up to four times longer than in unencrypted deployments. Furthermore, because flash-based embedded media (e.g., raw NAND, SPI NOR, or low-cost eMMC) exhibit non-deterministic write latency due to internal garbage collection and wear leveling, any delay in submitting writes increases the probability that multiple flush operations coalesce into a burst, hitting eMMC queue exhaustion.
To decouple the CPU overhead of cryptographic operations from underlying storage latency regressions, Linux tracing tools (perf and bpftrace) allow microarchitectural instrumentation of the application and the kernel boundary. The following bpftrace script attaches to userspace encryption functions and kernel sync syscalls, calculating latency distributions for both phases during continuous database updates:
TEXT
#!/usr/bin/env bpftrace

usdt:/usr/lib/libsqlite3.so:codec_encrypt_page_entry
{
    @start_encrypt[tid] = nsecs;
}

usdt:/usr/lib/libsqlite3.so:codec_encrypt_page_exit
/@start_encrypt[tid]/
{
    $duration = nsecs - @start_encrypt[tid];
    @encrypt_latency_us = hist($duration / 1000);
    delete(@start_encrypt[tid]);
}

tracepoint:syscalls:sys_enter_fdatasync
{
    @start_sync[tid] = nsecs;
}

tracepoint:syscalls:sys_exit_fdatasync
/@start_sync[tid]/
{
    $duration = nsecs - @start_sync[tid];
    @fdatasync_latency_us = hist($duration / 1000);
    delete(@start_sync[tid]);
}

interval:s:10
{
    print(@encrypt_latency_us);
    print(@fdatasync_latency_us);
    clear(@encrypt_latency_us);
    clear(@fdatasync_latency_us);
}
In environments where USDT probes are not compiled into the database binary, dynamic function tracing via uprobes on internal symbols provides equivalent visibility:
BASH
# Attach uprobe to the encryption function in SQLCipher or embedded library
perf probe -x /usr/lib/libsqlcipher.so.0 cipher_page_encrypt=sqlite3Codec
perf record -e probe_sqlcipher:cipher_page_encrypt,syscalls:sys_enter_fdatasync -p $(pgrep telemetry_agent) -- sleep 30
perf report --stdio
Complementing latency distribution tracing, tracking CPU time allocation using perf top or perf record -g isolates cipher engine CPU consumption relative to memory management:
BASH
# Record call graph profiles of the embedded process under ingestion pressure
perf record -F 997 -g -p $(pgrep telemetry_agent) -- sleep 20
perf report --stdio --no-children -G
Expected profiling output on a system without hardware crypto acceleration demonstrates that the cipher routines dominate the execution profile:
TEXT
Samples: 19K of event 'cycles',
Event count (approx.): 15894123512
  Overhead  Symbol
  --------  --------------------------------------------------------
    38.45%  [.] aes_xts_encrypt
    14.20%  [.] memcpy
     8.12%  [k] copy_user_enhanced_fast_string
     6.78%  [k] __alloc_pages_nodemask
     4.31%  [.] sqlite3BtreeInsert
     3.89%  [k] ext4_sync_file
     2.15%  [k] blk_mq_submit_bio
The 38.45% cycle saturation inside aes_xts_encrypt, coupled with high memcpy overhead, indicates that memory movement and transformation throttle ingestion throughput far earlier than the raw physical write ceiling of the storage media.
To quantify these trade-offs, benchmarks were conducted on an embedded target running Linux 5.15 on an ARM Cortex-A53 core (1.0 GHz, single-core test partition) with 512 MB LPDDR3 RAM and an eMMC 5.1 storage module. The workload consisted of continuous, multi-threaded inserts of 1 KB telemetry records into a B-tree table schema, configured with an explicit 4096-byte database page size and a buffer pool capacity fixed at 8 MB (2,048 pages). Three storage engine configurations were evaluated under an identical ingestion load of 500 transactions per second (tx/sec):
  1. Baseline Unencrypted (Standard SQLite WAL mode)
  2. AES-256-CBC with HMAC-SHA256 (SQLCipher Default configuration)
  3. AES-256-XTS (Custom Sector-Aligned Page Codec)
The measurements recorded commit latency distributions, memory pressure via the kernel Pressure Stall Information (PSI) subsystem, and resident memory growth:
Configurationp50 Write Latencyp95 Write Latencyp99 Write LatencyPeak Engine RSSMemory PSI (some avg10)
Plaintext Baseline1.82 ms3.95 ms7.42 ms12.4 MB0.00%
AES-256-CBC + HMAC4.88 ms12.14 ms34.60 ms28.6 MB4.12%
AES-256-XTS3.41 ms8.87 ms22.15 ms22.1 MB1.85%
AES-256-XTS exhibits superior latency compared to AES-256-CBC with HMAC. CBC mode requires sequential block-by-block chaining where block N depends on the output of block N-1, preventing vector parallelization, and subsequently mandates an HMAC integrity pass over the entire page. In contrast, XTS mode processes 16-byte blocks independently once the tweak is established. However, both encrypted configurations cause an exponential stretch in p99 tail latency relative to the baseline. The p99 latency degradation (jumping from 7.42 ms to 22.15 ms in XTS mode) is driven by thread preemption and buffer pool lock contention. When an fdatasync() flush is triggered, dirty pages must be serialized through the cipher. If multiple ingestion threads exhaust their pre-allocated buffer allocations, they stall on buffer pool latches, waiting for the single-threaded cipher transformation and sync completion. Memory pressure metrics reflect this instability. Kernel PSI telemetry inspected during the test run revealed elevated memory stalls:
BASH
cat /proc/pressure/memory
TEXT
some avg10=1.85 avg60=1.12 avg300=0.45 total=1847120
full avg10=0.00 avg60=0.00 avg300=0.00 total=0
The non-zero some avg10 value confirms that tasks were stalled on page allocation because transient bounce-buffer demands forced the Linux virtual memory manager to invoke background page reclaim routines (kswapd), reclaiming dentries and page cache entries to satisfy userspace memory requests.
When encrypted pages are passed to write() or pwrite64(), they transition into the kernel page cache as dirty file pages. By default, Linux employs aggressive dirty writeback thresholds tuned for server systems with gigabytes of RAM. The relevant kernel parameters are defined in /proc/sys/vm/:
INI
# Linux default dirty writeback thresholds
vm.dirty_background_ratio = 10
vm.dirty_ratio = 20
vm.dirty_writeback_centisecs = 500
vm.dirty_expire_centisecs = 3000
On an embedded node with 512 MB total RAM, available user space memory may only be 200 MB once system services and application runtimes initialize. A dirty_ratio of 20% allows up to 40 MB of dirty pages to accumulate in memory before the kernel suspends writing processes and forces direct synchronous writeback via balance_dirty_pages(). In transparent page encryption workloads, this creates a double-throttling condition:
  1. The embedded database engine buffers dirty pages internally up to its cache_size limit.
  2. When forced to flush, it rapidly transforms these pages through bounce buffers and invokes pwrite64() in a tight loop.
  3. The kernel page cache rapidly fills to vm.dirty_background_ratio (10%), triggering asynchronous kernel writeback threads (kworker/u:X or bdi-default-flush).
  4. If the database flushes faster than the physical flash can write (common on eMMC with 15–30 MB/s sustained random write ceilings), the kernel hits vm.dirty_ratio (20%).
  5. The database process is suspended mid-transaction by balance_dirty_pages(), forcing it to wait on block I/O drainage while holding internal database transaction mutexes.
This behavior can be observed by tracing kernel writeback throttle states:
BASH
# Trace occurrences of write throttling inside balance_dirty_pages
perf record -e writeback:balance_dirty_pages,writeback:bdi_dirty_ratelimiting -a -- sleep 10
perf report --stdio
When balance_dirty_pages activates, the latency of a simple pwrite64() can escalate from 25 microseconds to over 150 milliseconds, as the userspace process is forced to wait for physical storage controllers to complete page programs.
Resolving write stalls and excessive memory footprint in transparently encrypted embedded databases requires architectural adjustments across memory pooling, page sizing, and operating system writeback configuration.
To eliminate memory fragmentation and dynamic allocation stalls, the encryption codec must avoid calling standard library malloc() or free() during the write path. A pre-allocated, ring-buffered memory pool sized exactly to match the maximum concurrent dirty page flush quota must be initialized at startup:
C
#define ENCRYPT_PAGE_SIZE 4096
#define BOUNCE_POOL_PAGES 32

typedef struct {
    uint8_t buffer[ENCRYPT_PAGE_SIZE];
    uint32_t in_use;
} bounce_page_t;

typedef struct {
    bounce_page_t pool[BOUNCE_POOL_PAGES];
    pthread_mutex_t lock;
    pthread_cond_t cond;
} bounce_allocator_t;

static bounce_allocator_t g_bounce_allocator;

void bounce_allocator_init(void) {
    pthread_mutex_init(&g_bounce_allocator.lock, NULL);
    pthread_cond_init(&g_bounce_allocator.cond, NULL);
    for (int i = 0; i < BOUNCE_POOL_PAGES; i++) {
        g_bounce_allocator.pool[i].in_use = 0;
    }
}

uint8_t *acquire_bounce_page(void) {
    pthread_mutex_lock(&g_bounce_allocator.lock);
    while (1) {
        for (int i = 0; i < BOUNCE_POOL_PAGES; i++) {
            if (!g_bounce_allocator.pool[i].in_use) {
                g_bounce_allocator.pool[i].in_use = 1;
                pthread_mutex_unlock(&g_bounce_allocator.lock);
                return g_bounce_allocator.pool[i].buffer;
            }
        }
        pthread_cond_wait(&g_bounce_allocator.cond, &g_bounce_allocator.lock);
    }
}

void release_bounce_page(uint8_t *buf) {
    pthread_mutex_lock(&g_bounce_allocator.lock);
    for (int i = 0; i < BOUNCE_POOL_PAGES; i++) {
        if (g_bounce_allocator.pool[i].buffer == buf) {
            g_bounce_allocator.pool[i].in_use = 0;
            pthread_cond_signal(&g_bounce_allocator.cond);
            pthread_mutex_unlock(&g_bounce_allocator.lock);
            return;
        }
    }
    pthread_mutex_unlock(&g_bounce_allocator.lock);
}
This implementation guarantees an upper bound on userspace memory consumption for cryptographic operations (32 pages × 4 KB = 128 KB) regardless of ingestion burst volume, completely eliminating heap churn.
Database page sizes should be configured to match both the cipher block efficiency requirements and the physical block structure of flash media. While larger pages (e.g., 16384 bytes) amortize B-tree branch traversals, they severely penalize write latency when encrypted:
  • A 16 KB page requires 1,024 AES block transformations per write, monopolizing the CPU for up to 400 microseconds on software-only architectures.
  • Even a minor 64-byte row update requires re-encrypting and flushing the entire 16 KB page, causing significant write amplification.
For embedded engines subjected to continuous row-by-row telemetry appending, setting the page size strictly to 4096 bytes aligns with both the standard Linux virtual memory page size and the underlying eMMC allocation unit, minimizing writeback penalties.
SQL
-- Apply optimal page size before database schema initialization
PRAGMA page_size = 4096;
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA wal_autocheckpoint = 256;
Setting synchronous = NORMAL in WAL mode ensures that the engine only invokes fdatasync() at checkpoint intervals rather than on every individual transaction commit, shifting the serialization burden away from the telemetry ingestion path without sacrificing database structural integrity against application crashes.
To prevent the operating system from accumulating excessive dirty page caches that subsequently trigger severe direct-reclaim pauses, the Linux writeback subsystem must be tuned to force earlier, continuous, and smoother writeouts. The following configuration adjusts kernel writeback thresholds to trigger background flushing at lower absolute byte thresholds rather than percentages of total memory:
INI
# File: /etc/sysctl.d/99-embedded-storage.conf

# Start background writeback as soon as 4MB of dirty data accumulates
vm.dirty_background_bytes = 4194304

# Throttle processes at 16MB of dirty data to prevent long write stalls
vm.dirty_bytes = 16777216

# Wake up writeback flusher threads every 100 centiseconds (1 second)
vm.dirty_writeback_centisecs = 100

# Expire dirty pages after 500 centiseconds (5 seconds)
vm.dirty_expire_centisecs = 500

# Prevent aggressive inode/dentry cache eviction during memory pressure
vm.vfs_cache_pressure = 50
Applying these settings ensures that the Linux kernel continuously drains dirty database pages in small, manageable increments rather than allowing several megabytes of encrypted pages to pile up, preventing both flash write-queue saturation and severe fsync() write stalls.