Recursive AI

Benchmarks: MIME rows

All onemail docs

bench/OneMail.Benchmarks compares OneMail with MimeKit 4.18.2 (a released NuGet package, referenced only from bench/) on the same bytes, in memory, with BenchmarkDotNet 0.15.8. bench/run-mime-rows.sh OUTDIR builds, runs every row (3 launches, 3 warm-up and 10 measured iterations each), measures peak memory in separate processes and prints the table. Run it alone on the machine. The ratio is OneMail median time / MimeKit median time; below 1 is faster. The bar is a geometric mean ≤ 1.0 across rows and no row above 1.25.

Inputs

Picked deterministically from the corpora that tools/corpus/fetch.sh fetches (never committed):

NameWhat
enron-2k, enron-20kthe first file, in path order, of the 1-in-100 Enron sample of 1.5–2.5 KB and of 15–25 KB
dovecot-multipartthe first multipart message of 40–300 KB in the Dovecot test mbox
generated-attachment-6ma text part and a 4.5 MB random attachment as base64 (about 6 MB), fixed seed
mboxthe whole Dovecot test mbox, 2,859 messages

What each side does

RowOneMailMimeKit
parseMimeEntity.Parse over the bytesMimeMessage.Load over a MemoryStream of the bytes
headersparse, then Subject, From and To (address lists), Date, Message-ID, decodedparse, then the same five properties
bodiesparse, then GetDecodedContent() of every leafparse, then Content.DecodeTo a fresh MemoryStream for every MimePart in BodyParts
writeWriteTo(Stream.Null) of a parsed, unmodified message (parse not timed)WriteTo(Stream.Null) of a parsed message (parse not timed)
builda multipart with a text part and a 1 MB attachment (base64), written to Stream.Nullthe same with Multipart, TextPart, MimePart
mboxone shared splitter (a From line after a blank line opens a message), then each message parsed wholethe same splitter, then MimeMessage.Load of each range

Things that make the rows differ in kind, not only speed:

  • OneMail's parse reads headers and structure and leaves bodies in the input as ranges; MimeKit's default parse copies bodies into content streams. The parse and write rows therefore reward the lazy design; the bodies row is the like-for-like one.
  • MimeKit re-serialises its model on write; OneMail copies untouched ranges, which is what keeps output byte-identical.
  • The mbox row is parse per message over a split, not a mailbox reader. MimeKit's own mbox-mode parser is not used: it starts a message at every From line, including ones inside bodies, and throws on the fragments of this file.
  • Peak memory is measured by running one row's work in a loop for four seconds in its own process. The working-set figure is dominated by the runtime's allocation budget on a machine with a large cache, so read the allocation columns for memory behaviour.

Results (first run, no tuning needed)

Medians in ns per operation (per message for mbox); B/op is bytes allocated per operation.

RowInputOneMail x64MimeKit x64ratio x64ratio arm64OneMail B/opMimeKit B/op
parseenron-2k1,02013,4510.0760.1065,28019,448
parseenron-20k86521,3890.0400.0524,36039,360
parsedovecot-multipart4,83068,9270.0700.1109,920171,802
parsegenerated 6 MB96,6461,924,4950.0500.0725,3446,318,608
headersenron-2k2,79716,6990.1670.20510,32822,344
headersenron-20k2,26722,3560.1010.1108,32841,400
headersdovecot-multipart6,58876,7630.0860.12815,088174,570
headersgenerated 6 MB156,0431,877,7930.0830.07310,6886,321,655
bodiesenron-2k1,39313,3710.1040.1258,16020,904
bodiesenron-20k3,72321,5790.1730.12849,496101,208
bodiesdovecot-multipart154,182321,8360.4790.351503,617556,690
bodiesgenerated 6 MB6,057,21813,025,6920.4650.33116,778,00318,583,957
writeenron-2k631,5980.0390.0331841,752
writeenron-20k35214,8620.0240.0181844,696
writedovecot-multipart1,96588,7600.0220.0201846,912
writegenerated 6 MB94,8523,611,1870.0260.0221845,912
buildtext + 1 MB attachment530,959908,4330.5840.3314,112,65236,782
mboxdovecot, per message2,79223,4100.1190.1848,47528,393

Geometric mean 0.091 (x64, AMD Ryzen 9 7950X3D, 32 logical cores) and 0.093 (arm64, Apple M2, 8 cores); worst row 0.584 and 0.351. The one row where OneMail allocates more is build: it base64-encodes the attachment when the content is set, where MimeKit encodes while writing.

Benchmarks: Outlook-format rows (PST/OST, MSG, TNEF)

bench/run-outlook-rows.sh OUTDIR runs these rows. They use the same harness as the MIME rows (BenchmarkDotNet 0.15.8, 3 launches, 3 warm-up and 10 measured iterations; the 10k and 50k stores use 3 launches of 1 warm-up and 3 iterations because one walk takes seconds), the same ratio (OneMail median / comparator median, below 1 is faster) and the same bar: geometric mean ≤ 1.0 per area, no row above 1.25. Arguments after OUTDIR replace the default BenchmarkDotNet filter and skip the extra steps.

Comparators

Each is the released NuGet package, referenced only from bench/ and used through its public API. Versions are pinned in Directory.Packages.props.

AreaComparatorVersionLicenceBenchmarking permitted
PST/OSTXstReader.Api1.0.6 (2022-12)Microsoft Public License (MS-PL): "Copyright (c) 2016, Dijji; 2021, iluvadev, and released under Ms-PL License"Yes: MS-PL grants the right to use the software; nothing is copied, bundled or redistributed
MSGMsgReader6.2.0MITYes
TNEFMimeKit (TnefPart)4.18.2MITYes
PST/OST, native referencelibpff pffexport20180714 (Ubuntu noble pff-tools)LGPL-3.0-or-laterRun as a binary only; reported, not gating

The comparator packages pull in a System.Security.Cryptography.Pkcs build that carries an advisory (NU1902/NU1903). The bench project is never shipped, so it suppresses those two audit warnings and nothing else.

Inputs

NameWhat
cfreds-ostthe NIST CFReDS informant's Outlook 2013 OST (14 messages, 49 folders); x64 only (the fetch needs a 7-Zip and 20 GB)
tika-pst, tika-bodytypesApache Tika's two Unicode PSTs (7 and 4 messages)
gen-u1k, gen-u10k, gen-u50kgenerated Unicode stores of 1,000, 10,000 and 50,000 messages (17 MB, 165 MB, 826 MB) over 20 folders; per message a plain body, an HTML body for every second one, one to four recipients, and an attachment on 70 % (2 to 22 KB, one in 500 is 1 MB); 50,000 messages carry 490 MB of attachments
gen-k4-1k, gen-k4-10kthe same mail in the 4 KiB-page layout (OneMail only, see below)
msg-smallest, msg-median, msg-largest, msg-corpusthe 53 Tika and POI MSG files that both libraries open (of 58): the smallest, the median and the largest by size (one has 1,321 recipients), and all 53 per operation
poi-*, tika-testwinmailthe six TNEF streams of the POI and Tika corpora with content (the 53-byte oom.tnef has none)

The generated stores come from the test-side PST writer (tests/OneMail.Tests/Pst), which now leaves a free slot for each AMap page a larger file needs (MS-PST 2.2.2.7.2) and sets MSGFLAG_HASATTACH. libpff 20180714 opens the stores (pffinfo, and pffexport -m items exports them), OneMail and XstReader read them to the same counts, and a unit test builds a 20 MB store in every layout and reads it back with no defects. libpff's recovery scan (pffexport -m all) fails on them, so the reference below uses -m items. The writer holds the file in one array, so the largest store it can make is about 1.5 GB; the 826 MB store is the largest measured.

What each side does

Every message: subject, sender name and address, submit time, every recipient (name and address), the body, and every attachment's bytes read to the end into a discarded buffer. A verify step runs both sides over every input first and prints what each saw (verify.tsv); messages, recipients, attachments and attachment bytes agree everywhere except as listed below.

RowOneMailComparator
PSTPstStore.TryOpen(path), every folder, EnumerateMessages(Normal), best body (stored HTML, else RTF decompressed and de-encapsulated, else text), OpenData() per attachmentnew XstFile(path), RootFolder, Folders/Messages recursively, Body.Text, SaveToStream into a counting stream per file attachment; message.ClearContents() off
MSGMsgFile.TryOpen(path), then the same fields and all three bodies (text, HTML from the property or de-encapsulated from RTF, RTF decompressed)new Storage.Message(path), Subject, Sender, Recipients, SentOn, BodyText, BodyHtml, BodyRtf, Attachment.Data
TNEFTnefMessage.Read(bytes), then the PST fieldsTnefPart.ConvertToMessage() over the bytes, then the message's fields, body and Attachments decoded to a counting stream

Things that make the rows differ in kind or in work:

  • ClearContents. XstReader documents ClearContents() as the way to release what a message cached. On the 50,000-message store it made the walk 14 times slower (139 s against 9.9 s) and its peak working set no lower (157 MiB against 150), so the timed rows leave it off: the comparator's fastest correct use. The memory section shows both.
  • XstFile.Dispose. It starts a background release that reopens the file; in a loop over a short walk that thread threw after the file was closed and ended the process, on Linux and on macOS. The rows leave the file to the garbage collector instead.
  • 4 KiB layout. XstReader.Api 1.0.6 throws XstException: Unexpected header in compressed data stream on every 4 KiB-page store this writer makes, so those rows have no comparator and no ratio (OneMail alone: 1,000 messages in 92 ms on x64 and 100 ms on arm64, 10,000 in 1.19 s and 1.35 s). No real 4 KiB store was available to tell a writer fault from a reader gap (libpff 20180714 opens the layout but cannot read tables stored in data trees, which these stores use).
  • MSG corpus. MsgReader read 40 attachments (268,647 bytes) where OneMail read 41 (274,743 bytes) across the 53 files: one 6,096-byte attachment is not handed out by MsgReader. The ratio is on slightly unequal work, in the comparator's favour.
  • Folders. The folder counts differ (OneMail also counts search folders, 49 against 41 in the OST); messages agree.
  • PST bodies. XstReader hands out one body (Body.Text); OneMail's PST row reads the best one, so the work is matched. MSG reads all three on both sides because MsgReader computes all three on request.
  • Memory. Peak working set is measured in a separate process that repeats one row's work for at least four seconds, with DOTNET_GCgen0MaxBudget set to 32 MiB so the first-generation budget of a large-cache machine does not hide the live set. A background thread samples the working set every 10 ms (the runtime's own peak counter reads 0 on macOS). Single-message rows (MSG, TNEF) sit on the runtime's baseline and say nothing; the PST rows and the curves are the memory evidence.

Results

Medians; B/msg is bytes allocated per message (for msg-corpus, per file). Ratios are OneMail / comparator.

AreaInputMsgsOneMail x64Comparator x64ratio x64ratio arm64OneMail B/msgComparator B/msg
PSTcfreds-ost143.71 ms194.15 ms0.019n/a95,386605,609
PSTtika-pst7596.4 µs2.29 ms0.2610.25284,463437,083
PSTtika-bodytypes4490.6 µs3.00 ms0.1640.16984,298767,458
PSTgen-u1k1,00071.63 ms176.79 ms0.4050.40919,485220,845
PSTgen-u10k10,000714.7 ms1,800.5 ms0.3970.40819,374219,661
PSTgen-u50k50,0004,117 ms9,205 ms0.4470.47519,371220,179
MSGmsg-smallest121.4 µs46.5 µs0.4600.569107,36280,801
MSGmsg-median1137.9 µs876.0 µs0.1570.180293,9051,272,393
MSGmsg-largest111.71 ms440.99 ms0.0270.02317,491,38853,247,584
MSGmsg-corpus5321.75 ms484.67 ms0.0450.043656,3601,925,639
TNEFpoi-simple19.1 µs38.7 µs0.2340.243110,06962,446
TNEFpoi-sample1135.7 µs140.2 µs0.2550.281119,754427,807
TNEFtika-testwinmail172.0 µs224.7 µs0.3200.370181,977276,927
TNEFpoi-quick184.1 µs230.6 µs0.3650.372181,982276,930
TNEFpoi-attachments193.7 µs260.7 µs0.3600.239124,1631,204,040
TNEFpoi-bug639551146.1 µs397.1 µs0.3680.248173,0981,880,228
AreaGeomean x64Worst x64Geomean arm64Worst arm64Bar
PST/OST vs XstReader.Api0.197 (6 rows; 0.314 without the OST)0.4470.320 (5 rows, no OST)0.475met
MSG vs MsgReader0.0960.4600.1010.569met
TNEF vs MimeKit0.3120.3680.2870.372met

Both machines pass every area and every row with room to spare, so no tuning was done. Two rows allocate more than the comparator: the smallest MSG (107 KB against 81 KB) and the smallest TNEF stream (110 KB against 62 KB). Both are fixed per-message costs that vanish into the time ratio (0.46 and 0.23).

Memory over the large stores

Peak working set, repeating the walk (KiB, OneMail / XstReader.Api, ClearContents off):

Storex64arm64
gen-u1k (17 MB)102,136 / 116,90066,672 / 90,416
gen-u10k (165 MB)101,304 / 123,29666,240 / 105,632
gen-u50k (826 MB)101,080 / 152,79266,320 / 119,488

OneMail's working set does not move between 1,000 and 50,000 messages (a file 50 times larger, 826 MB, in about 100 MB or 66 MB, most of it the runtime and its allocation budget). XstReader.Api grows with the store. The curve samples agree (working set in MiB at 10 %, 25 %, 50 %, 75 % and 95 % of the 50,000-message walk): OneMail x64 88, 95, 98, 98, 99 and arm64 58, 63, 63, 64, 64; XstReader.Api x64 102, 118, 129, 142, 146 and arm64 70, 85, 95, 108, 114. With ClearContents after every message its working set is flat but noisy (x64 75, 86, 135, 129, 119) and the walk takes 139 s on x64 and 144 s on arm64. The generated stores top out at 826 MB, so the claim is for that size; a 50 GB store needs a streaming test writer.

Native reference

pffexport -q -m items -f text (libpff 20180714, x64, writes every item to disk, so it is not like-for-like), wall seconds, median of 3: gen-u1k 0.24 s (OneMail 0.072 s), gen-u10k 2.73 s (0.715 s), gen-u50k 20.09 s (4.12 s), peak RSS 207 MB for 50,000 messages. The real files take under 0.03 s. Reported, not gating.

Machines and runs

x64arm64
CPUAMD Ryzen 9 7950X3D, 32 logical cores, AVX-512Apple M2, 8 cores
Runtime, SDK.NET 10.0.13, SDK 10.0.402.NET 10.0.11, SDK 10.0.400
Jobwq bench 000847, 14:32 to 15:01 UTC, 28.5 min of runningwq bench on the Mac, 14:01 to 14:26 UTC
Load, 1/5/15 min, before6.01 / 8.49 / 10.41 (the previous job's tail)2.08 / 3.19 / 3.11
Load, 1 min, during (every 2 min)1.56 to 2.93, almost all of it this job2.05 to 2.42
Load after1.65 / 1.89 / 3.451.77 / 2.00 / 2.26

BenchmarkDotNet 0.15.8, MimeKit 4.18.2, MsgReader 6.2.0, XstReader.Api 1.0.6. The raw tables are report.tsv, peak.tsv, curve.tsv, verify.tsv and pffexport.tsv in each run's output directory. Two script faults found after the runs and fixed in the script: the TNEF peak row for tika-testwinmail was routed to the PST path and has no line (it is the same 66,276 bytes as poi-quick), and on macOS the theirs-clear tag in curve.tsv was not applied (the second theirs block per store is the ClearContents run).