Repository navigation
Speed up split and join on names that need no escaping - #154
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Speeds up
dns_domain:split/1anddns_domain:join/1,2on names whose labels need no escaping, which is nearly all of them, and keeps names that do close to what they cost onmain, as whoever sends a query can choose those. The first commit only moves the SWAR macros abovesplit/1and renamesdo_split/2tosplit_esc/2, and compiles to the same code as before, so the other two hold only changes in behaviour.split
split/1walked 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, asdot_at/2already does forto_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 onmainplus one word test.to_wire/1,3split 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/1againstmain, median per call, OTP 29.1.2, Elixir 1.20.4, Benchee 1.5.1, both modules compiled with this repo'serl_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.www.example.comexample.com.very.long.subdomain.with.many.labels.example.com1.0.0.127.in-addr.arpacomtest\.label.coma\.b\.c\.d\.e\.f\.g\.h.example.comcomcoma\.b.c\.d.e\.fa\.b\.foo.example.comAcross both runs on both machines, names without escapes split in 0.29–0.50 of the time
maintakes. Names with escapes go throughmain'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 ofmainmoved by up to 17% between its runs; the 9950X3D's, 0.97–1.10, are steadier.Memory
mainbuilds 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.www.example.comexample.com.very.long.subdomain.with.many.labels.example.com1.0.0.127.in-addr.arpacomtest\.label.com, 63 escaped dots or backslashes +coma\.b.c\.d.e\.f,\.foo.example.com,a\.b\.c\.d\.e\.f\.g\.h.example.coma\.bA name with a
\keeps and creates the same off-heap binaries as onmain, labels after its first escaped one included, and allocates 40 bytes more heap per call.join
join/1,2escaped every label byte by byte into a binary it appended to, so evenwww.example.comtook 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/1splits 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 withiolist_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 overmainis 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 -Sshowsprivate_appendin every construction on the join path.Benchmarks
join/1againstmain, median per call, set up as forsplit/1, labels as separate binaries assplit/1returns them, the first run on each machine. The Xeon Silver 4214 is a server running the officialhexpm/elixirimage pinned to one core.www.example.com*.example.comvery.long.subdomain.with.many.labels.example.com1.0.0.127.in-addr.arpacomtest.label+coma.b.c.d.e.f.g.h+example+comcomcomab+ 63 dots*+.*+\a+.a+..+comAcross 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
maintakes, 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% ofmain.Memory
On
mainevery 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:www.example.com,*.example.com1.0.0.127.in-addr.arpavery.long.subdomain.with.many.labels.example.comcomAllocation per call moves the other way on longer names: the heap Benchee measures grows, from 184 to 232 bytes on
www.example.comand from 384 to 664 on the 8-label name, while the 256 off-heap bytesmainallocates 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:main, with 1.9–2.5x its heap allocation, on names of many short escaped labels such asa\.b.c\.d.e\.for 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 ofmain, are not worth that, as such names are rare and names of escapes are what a sender can choose..or\, going byte by byte to it before testing words again, was 0.69–0.93 ofmainon 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.erlc -Sshows the sameprivate_appendconstructions asmain.binary:copy/1was 0.40–0.64 ofmainon 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:escape_label_chunk/2from Speed up from_wire on labels that need escaping #153, whole-label appends afterhas_special/1, or the dot and the label in one append: 0.58–1.07 ofmainon common names, but 1.5–2x slower on labels made of dots or backslashes, asescape_label_chunk/2tests a word on every byte of such a label, which costs more than the plain byte loop.iolist_to_binary/1and copying it into the buffer: 1.2–1.5xmainon 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.. Restartingmain's loop from the first label instead was up to 1.6x on the M4 Pro.mainon the Xeon on 7 or 8 one-byte labels followed by., as appending a one-byte binary costs more there than appending a byte..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.1.0.0.127.in-addr.arpa.<<W:Size>> = Label, instead of a clause per size: 1.4–1.8x slower on one-byte labels. Making that test the first clause ofhas_special/1slowed long labels by 1.5–2.5x.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,.comor.., 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. Forjoin/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/1andto_wire/3matchedmainon 391,905 names andjoin/2on 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, anddns_domaincoverage is 98%.