Skip to content

Speed up split and join on names that need no escaping - #154

Merged
NelsonVides merged 3 commits into
mainfrom
perf/split-swar
Oct 22, 2026
Merged

NelsonVides merged 3 commits into
mainfrom
perf/split-swar

Conversation

@NelsonVides

Copy link
Copy Markdown
Contributor

Speeds up dns_domain:split/1 and dns_domain:join/1,2 on names whose labels need no escaping, which is nearly all of them, and keeps names that do close to what they cost on main, as whoever sends a query can choose those. The first commit only moves the SWAR macros above split/1 and renames do_split/2 to split_esc/2, and compiles to the same code as before, so the other two hold only changes in behaviour.

split

split/1 walked a name with guard chains on 8, 4 and 2 bytes, building each label byte by byte in an accumulator. A dot ends every label, so at every label boundary each chain failed, the walker fell back to a single byte, and then tried 8 bytes again on the next one. That is the common case, not a crafted one.

It now finds the next . or \ 7 bytes at a time, as dot_at/2 already does for to_wire/3, and tells which it found. A label without \ is matched out of the name in one piece. From the first label with a \ on, the previous walker splits the rest of the name in one pass, exactly as before, so a name of escaped labels costs what it did on main plus one word test. to_wire/1,3 split escaped names with the same function. The walker itself is untouched, except for two clauses for an empty name and for ., which it can no longer be called with.

Labels are still binaries of their own, as before: a binary of 64 bytes or less matched out of another is copied by the runtime, so a label keeps neither the name nor a larger binary the name is part of alive. That holds on OTP 27, 28 and 29, and a new test checks it.

Benchmarks

split/1 against main, median per call, OTP 29.1.2, Elixir 1.20.4, Benchee 1.5.1, both modules compiled with this repo's erl_opts, the first of two runs on each machine. The M4 Pro is a MacBook, the Ryzen 9 9950X3D a desktop pinned to one core.

Name M4 Pro main M4 Pro this PR 9950X3D main 9950X3D this PR
www.example.com 136.7 ns 52.9 ns (0.39) 155.7 ns 64.5 ns (0.41)
example.com. 92.9 ns 39.6 ns (0.43) 106.2 ns 53.2 ns (0.50)
very.long.subdomain.with.many.labels.example.com 525.8 ns 163.3 ns (0.31) 560.0 ns 182.7 ns (0.33)
1.0.0.127.in-addr.arpa 332.1 ns 112.5 ns (0.34) 329.8 ns 135.4 ns (0.41)
IPv6 PTR name (34 labels) 2068.8 ns 674.2 ns (0.33) 2287.2 ns 808.6 ns (0.35)
63-byte label + com 139.2 ns 55.8 ns (0.40) 159.4 ns 70.2 ns (0.44)
four 63-byte labels (255 bytes) 434.6 ns 169.6 ns (0.39) 498.5 ns 214.0 ns (0.43)
127 one-byte labels 7829.2 ns 2939.2 ns (0.38) 8643.9 ns 3185.9 ns (0.37)
test\.label.com 111.7 ns 105.8 ns (0.95) 113.0 ns 114.2 ns (1.01)
a\.b\.c\.d\.e\.f\.g\.h.example.com 282.5 ns 240.4 ns (0.85) 267.7 ns 295.3 ns (1.10)
63 escaped dots + com 559.6 ns 503.3 ns (0.90) 545.2 ns 563.5 ns (1.03)
63 escaped backslashes + com 485.4 ns 503.8 ns (1.04) 546.7 ns 566.5 ns (1.04)
a\.b.c\.d.e\.f 149.6 ns 157.9 ns (1.06) 168.1 ns 183.9 ns (1.09)
50 × a\.b 4236.2 ns 3630.0 ns (0.86) 4076.0 ns 4335.8 ns (1.06)
\.foo.example.com 181.7 ns 146.7 ns (0.81) 169.6 ns 164.7 ns (0.97)

Across both runs on both machines, names without escapes split in 0.29–0.50 of the time main takes. Names with escapes go through main's own loop from their first escaped label on, after a word test that finds the \ and a restart of the walk at that label, a cost that doesn't grow with the number of escaped labels, so they take up to 10% longer. The M4 Pro's figures below 1 for them are its noise, as medians of main moved by up to 17% between its runs; the 9950X3D's, 0.97–1.10, are steadier.

Memory

main builds every label by appending to an empty binary, which gives each one a 256-byte buffer off the process heap that it keeps for as long as the label lives. A label matched out whole is a small binary on the heap instead. Bytes per call on OTP 29: allocated is the heap Benchee measures plus the off-heap binaries created, and retained is what a kept result still holds after a garbage collection. OTP 27 and 28 create and keep the same off-heap binaries.

Name allocated main → this PR retained main → this PR
www.example.com 1048 → 352 (0.34) 1008 → 120 (0.12)
example.com. 712 → 264 (0.37) 672 → 80 (0.12)
very.long.subdomain.with.many.labels.example.com 2728 → 1208 (0.44) 2688 → 328 (0.12)
1.0.0.127.in-addr.arpa 2056 → 824 (0.40) 2016 → 240 (0.12)
IPv6 PTR name (34 labels) 11464 → 5752 (0.50) 11424 → 1360 (0.12)
63-byte label + com 712 → 256 (0.36) 672 → 136 (0.20)
four 63-byte labels (255 bytes) 1384 → 744 (0.54) 1344 → 384 (0.29)
127 one-byte labels 42683 → 20552 (0.48) 42672 → 5080 (0.12)
test\.label.com, 63 escaped dots or backslashes + com 712 → 752 (1.06) 672 → 672
a\.b.c\.d.e\.f, \.foo.example.com, a\.b\.c\.d\.e\.f\.g\.h.example.com 1048 → 1088 (1.04) 1008 → 1008
50 × a\.b 16840 → 16880 (1.00) 16800 → 16800

A name with a \ keeps and creates the same off-heap binaries as on main, labels after its first escaped one included, and allocates 40 bytes more heap per call.

join

join/1,2 escaped every label byte by byte into a binary it appended to, so even www.example.com took one append per byte and an off-heap 256-byte buffer, which the result keeps for as long as it lives. erldns calls it for every wildcard lookup (erldns_records:wildcard_qname/1 splits the query name and joins * with the rest), when looking up delegations by labels, and when signing.

Each label is now tested for . and \ once, before any copying. A label shorter than a word, which is most of them, is tested in one step: its size picks a clause that reads it as one integer and runs the existing SWAR test on it, the missing bytes reading as zeros, which are neither . nor \. A longer one is scanned a word at a time up to its first . or \. Up to 8 clean labels are made into one binary of the right size with iolist_to_binary/1, so a short name needs no buffer at all. Past 8, appending costs less than a longer iolist, so the first 8 go into a binary in one construction and the rest are appended whole, those of one or two bytes, as in reverse lookup names, as bytes. At the first label to escape, the clean labels before it are appended whole, and that label too up to its first . or \, from where the previous loop does the rest, unchanged. No byte is tested twice, so whatever the input, the cost over main is at most a test per label up to the first one to escape. The accumulator keeps the compiler's private append wherever it is used: erlc -S shows private_append in every construction on the join path.

Benchmarks

join/1 against main, median per call, set up as for split/1, labels as separate binaries as split/1 returns them, the first run on each machine. The Xeon Silver 4214 is a server running the official hexpm/elixir image pinned to one core.

Labels M4 Pro 9950X3D Xeon 4214
www.example.com 97 → 51 ns (0.52) 105 → 54 ns (0.51) 354 → 197 ns (0.56)
*.example.com 85 → 48 ns (0.57) 96 → 51 ns (0.53) 327 → 188 ns (0.57)
very.long.subdomain.with.many.labels.example.com 260 → 125 ns (0.48) 277 → 130 ns (0.47) 990 → 486 ns (0.49)
1.0.0.127.in-addr.arpa 142 → 84 ns (0.59) 146 → 85 ns (0.59) 476 → 315 ns (0.66)
IPv6 PTR name (34 labels) 422 → 306 ns (0.73) 468 → 329 ns (0.70) 1464 → 1109 ns (0.76)
63-byte label + com 312 → 103 ns (0.33) 360 → 95 ns (0.26) 1120 → 369 ns (0.33)
four 63-byte labels 1011 → 245 ns (0.24) 1253 → 228 ns (0.18) 3992 → 985 ns (0.25)
127 one-byte labels 1162 → 721 ns (0.62) 1518 → 927 ns (0.61) 4716 → 2892 ns (0.61)
85 two-byte labels 1094 → 572 ns (0.52) 1422 → 734 ns (0.52) 4452 → 2385 ns (0.54)
63 three-byte labels 1049 → 822 ns (0.78) 1354 → 981 ns (0.72) 4286 → 3853 ns (0.90)
51 four-byte labels 1036 → 667 ns (0.64) 1338 → 794 ns (0.59) 4247 → 3116 ns (0.73)
31 seven-byte labels 970 → 476 ns (0.49) 1246 → 551 ns (0.44) 4001 → 2024 ns (0.51)
test.label + com 82 → 90 ns (1.10) 100 → 102 ns (1.02) 339 → 359 ns (1.06)
a.b.c.d.e.f.g.h + example + com 137 → 149 ns (1.09) 160 → 175 ns (1.09) 551 → 618 ns (1.12)
63 dots + com 266 → 278 ns (1.04) 352 → 351 ns (1.00) 1157 → 1144 ns (0.99)
63 backslashes + com 268 → 275 ns (1.03) 344 → 370 ns (1.08) 1181 → 1171 ns (0.99)
8 × ab + 63 dots 365 → 360 ns (0.99) 470 → 458 ns (0.97) 1583 → 1560 ns (0.99)
* + . 42 → 49 ns (1.15) 48 → 59 ns (1.25) 170 → 184 ns (1.08)
* + \ 47 → 51 ns (1.09) 49 → 59 ns (1.19) 168 → 186 ns (1.10)
7 × a + . 103 → 108 ns (1.04) 117 → 135 ns (1.16) 401 → 471 ns (1.17)
8 × a + . 114 → 104 ns (0.92) 140 → 130 ns (0.93) 436 → 522 ns (1.19)
62 bytes ending in . + com 269 → 101 ns (0.37) 347 → 108 ns (0.31) 1119 → 386 ns (0.34)

Across five runs, one on the M4 Pro and two on each of the others, names without escapes join in 0.18–0.90 of the time main takes, and the common ones above in 0.47–0.66. Names with a label to escape take 0.31–1.29: the test that finds a label's . or \ saves the walk over the bytes before it, and what is left is a test per clean label before it, a cost that doesn't grow with the length of the input. The most it shows on is the shortest names, * or up to 8 one-byte labels followed by one to escape, at up to 18 ns more on the M4 Pro and the 9950X3D and up to 86 ns on the Xeon, and a label made of dots or backslashes stays within 8% of main.

Memory

On main every call creates a 256-byte binary off the process heap, and a kept result holds it. Bytes a kept result retains after a garbage collection, heap and off-heap, the same on OTP 27, 28 and 29:

Labels main this PR
www.example.com, *.example.com 320 32
1.0.0.127.in-addr.arpa 320 40
very.long.subdomain.with.many.labels.example.com 320 64
63-byte label + com 320 131
more than 8 labels, or a label to escape 320 320

Allocation per call moves the other way on longer names: the heap Benchee measures grows, from 184 to 232 bytes on www.example.com and from 384 to 664 on the 8-label name, while the 256 off-heap bytes main allocates on every call go away for names of up to 8 clean labels. Names with a label to escape allocate up to 320 bytes more heap, on 7 × a + .. That is short-lived garbage, and the timings above include collecting it.

Alternatives measured

For split/1:

  • Going back to the word scan after each escaped label, so that clean labels after one are still matched out whole: up to 1.6x slower than main, with 1.9–2.5x its heap allocation, on names of many short escaped labels such as a\.b.c\.d.e\.f or 50 × a\.b, as every escaped label paid for a sub-binary of the rest of the name, two match contexts and a second read of its first word. Its gains on names that mix escaped and clean labels, 0.4–0.9 of main, are not worth that, as such names are rare and names of escapes are what a sender can choose.
  • Keeping the walker and skipping the failed chains once a word is found to hold a . or \, going byte by byte to it before testing words again, was 0.69–0.93 of main on most names without escapes (1.09 on the 8-label one) and up to 1.27 on names with them: an escape is two bytes, and the old guard chains fail on its first byte for less than a word test costs.
  • Locating labels as here but appending escaped labels onto their clean prefix, already matched out of the name, lost the compiler's private append on the label, and names made of escapes were 3.5–4.2x slower with 60x the allocation. Handing escaped labels to the walker from their start, with an empty accumulator, keeps private append everywhere: erlc -S shows the same private_append constructions as main.
  • Wrapping each label in binary:copy/1 was 0.40–0.64 of main on names without escapes, against 0.32–0.48 without, and allocated more heap: it copies labels the runtime has already copied.

For join/1,2:

  • Every label through escape_label_chunk/2 from Speed up from_wire on labels that need escaping #153, whole-label appends after has_special/1, or the dot and the label in one append: 0.58–1.07 of main on common names, but 1.5–2x slower on labels made of dots or backslashes, as escape_label_chunk/2 tests a word on every byte of such a label, which costs more than the plain byte loop.
  • At the first label to escape, making the clean labels gathered so far into a binary with iolist_to_binary/1 and copying it into the buffer: 1.2–1.5x main on 7 or 8 one-byte labels followed by .; and walking a long label byte by byte after the test had found its . or \: up to 1.2x on a 62-byte label ending in .. Restarting main's loop from the first label instead was up to 1.6x on the M4 Pro.
  • Appending the clean labels before one to escape as binaries whatever their size: 1.3–1.4x main on the Xeon on 7 or 8 one-byte labels followed by ., as appending a one-byte binary costs more there than appending a byte.
  • Finding the offset of the first . or \ for labels shorter than a word too, from the SWAR flags: up to 25% slower on names without escapes, as short labels lost their inlined guard test.
  • An iolist for every name, of any length: the fastest on short names, but up to 1.85x slower on 127 one-byte labels, as each label adds two iolist elements.
  • Switching from the iolist to appending after 4 labels instead of 8: up to 1.33x slower on 1.0.0.127.in-addr.arpa.
  • Testing a short label with a match of its own size, <<W:Size>> = Label, instead of a clause per size: 1.4–1.8x slower on one-byte labels. Making that test the first clause of has_special/1 slowed long labels by 1.5–2.5x.
  • Clauses for one- and two-byte labels in the first 8 labels as well: 5–13% slower on common names, and no faster on short ones.

Tests

For split/1, new cases split every name of up to 9 bytes over ., \ and a plain byte, and a dot, an escape, a lone \ or a byte one bit away from . or \ at every position of labels up to 64 bytes, followed by nothing, .com or .., all against a byte-by-byte reference, errors included. Another checks that labels split from a slice of a larger binary don't reference it. For join/1,2, new cases join every label of up to 6 bytes over ., \, a plain byte and the bytes one bit away from . and \, alone, first and last of three labels; a byte to escape, or one bit away from one, at every position of labels up to 64 bytes; and one label to escape, or one that needs no escaping, at every position of names of up to 12 labels of every size up to 9 bytes, so it comes first, among the first 8 labels or after them, all against a byte-by-byte reference, as subdomain and as fqdn. During development, split/1, to_wire/1 and to_wire/3 matched main on 391,905 names and join/2 on 273,004 label lists, errors included, and every change to the new code that we tried which alters its output makes these tests fail. fmt, lint, xref, dialyzer and ct (812) pass, and dns_domain coverage is 98%.

No functional change, in preparation for the next commit: split/1 is
about to use the SWAR macros, which have to be defined before their
first use, and the walker it has now is about to be kept only for
labels that hold an escape.

The macro block moves unchanged, and the walker and its callers are
renamed and nothing else. With do_split renamed, the module's abstract
code and erlc -S output are the same as before but for line numbers.
split/1 built every label byte by byte, and its 8, 4 and 2-byte guard
chains all failed at each dot. It now finds the next '.' or '\' 7 bytes
at a time and matches a clean label out of the name whole. From the
first label with a '\' on, the old walker splits the rest of the name
in one pass, as before.

Names without escapes split 2.0-3.5x faster, and their labels hold
3.5-8x less memory, as each no longer keeps a 256-byte buffer. Names
with escapes are up to 10% slower, a cost that doesn't grow with the
number of escapes. Labels remain copies, not references into the name.
join/2 escaped every label byte by byte into a binary it appended to,
so even www.example.com took an append per byte and a 256-byte buffer.
Up to 8 labels without '.' or '\' now make one binary of the right
size, and past 8 they are appended whole. Each byte is tested once:
the clean labels before one to escape, and that label up to its first
'.' or '\', are appended whole before the old loop takes over.

Names without escapes join 1.5-2x faster, up to 5.5x with long labels,
and a short one no longer keeps a 256-byte buffer. Names with escapes
take at most 1.3x as long, on the shortest names.
@NelsonVides NelsonVides self-assigned this Oct 22, 2026
@NelsonVides NelsonVides added the enhancement New feature, enhancement or code changes, not related to defects label Oct 22, 2026
@NelsonVides NelsonVides changed the title Perf/split swar Speed up split and join on names that need no escaping Oct 22, 2026
@NelsonVides
NelsonVides merged commit a33adaf into main Oct 22, 2026
6 checks passed
@NelsonVides
NelsonVides deleted the perf/split-swar branch October 22, 2026 11:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature, enhancement or code changes, not related to defects

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant