[Pitch] Standard Network Address types

Recently I’ve been working on a package named swift-dns, which is:

A Swift DNS library built on top of SwiftNIO; aiming to provide DNS client, resolver and server implementations.

I’ve been thinking about decoupling some IP and domain name types that can be useful outside that package as well, and putting them into a dedicated package.

Most of the code is already ready, living in the swift-dns repository, in Sources/DNSModels/NetworkAddress.

Here’s what I’m thinking right now about how to make these useable as a standalone package.

  • There will be a package named like “NetworkAddress“.
  • A few products, one dedicated to IPAddress types, one to Domain Name, one for compatibility between IP and Domain Name, and another that just re-exports all.
  • Current Domain Name implementation contains IDNA compatibility code (for those unfamiliar, IDNA helps with non-ascii domain names, for example if a domain name is in Persian, or Chinese). I’m thinking of disabling IDNA compatibility by default, and introducing an “IDNA“ trait that reenables it when enabled. The expectation would be that enabling the trait is on end-user, not any libraries that might use this “NetworkAddress“ library.

Right now the main API consists of a few types:
IPv4Address, IPv6Address, IPAddress, DomainName, CIDR.

All these types are self-explanatory, and all of them contain optimized implementations for common stuff, such as en/decoding to/from String/ByteBuffer, as well as IP < –- > DomainName conversions.
That also means that for now this package would be relying on SwiftNIO, until we have a better solution for a “bag of bytes“ in the ecosystem, or if I find a reasonable way to make the types independent of ByteBuffer.

The CIDR type contains a somewhat simple CIDR implementation, providing a way to do containment checks and initializations of the CIDR type through trivial bitwise operations, as well as optimized String en/decoding implementations.

Another thing to mention is that currently a bunch of the implementations require macOS 26 on macOS, due to usage of spans, specially the UTF8Span of String. Most if not all of these implementations can be back-deployed if needed, although with worse performance.

There are currently a good amount of tests, as well as benchmarks for these types as well.
The latest results can be found in the benchmark CI runs in the “Summary“ section. Currently the latest CI run is this one.

The current benchmark results are as below:



```
Host 'd20e4aedf073' with 2 'x86_64' processors with 7 GB memory, running:
#71-Ubuntu SMP PREEMPT_DYNAMIC Tue Jul 22 16:52:38 UTC 2025 (Hetzner - Falkenstein)
```

## DomainName

### Equality_Check_CPU_20M

| Metric                 |        p0 |       p25 |       p50 |       p75 |       p90 |       p99 |      p100 |   Samples |
|:-----------------------|----------:|----------:|----------:|----------:|----------:|----------:|----------:|----------:|
| Time (user CPU) (ms) * |       260 |       260 |       260 |       260 |       270 |       270 |       270 |        20 |

### Equality_Check_Malloc

| Metric                 |        p0 |       p25 |       p50 |       p75 |       p90 |       p99 |      p100 |   Samples |
|:-----------------------|----------:|----------:|----------:|----------:|----------:|----------:|----------:|----------:|
| Malloc (total) *       |         0 |         0 |         0 |         0 |         0 |         0 |         0 |        10 |

### app-analytics-services_dot_com_Binary_Parsing_CPU_2M

| Metric                 |        p0 |       p25 |       p50 |       p75 |       p90 |       p99 |      p100 |   Samples |
|:-----------------------|----------:|----------:|----------:|----------:|----------:|----------:|----------:|----------:|
| Time (user CPU) (ms) * |       180 |       180 |       180 |       180 |       190 |       190 |       190 |        28 |

### app-analytics-services_dot_com_Binary_Parsing_Malloc

| Metric                 |        p0 |       p25 |       p50 |       p75 |       p90 |       p99 |      p100 |   Samples |
|:-----------------------|----------:|----------:|----------:|----------:|----------:|----------:|----------:|----------:|
| Malloc (total) *       |         1 |         1 |         1 |         1 |         1 |         1 |         1 |        10 |

### app-analytics-services_dot_com_String_Parsing_CPU_200K

| Metric                 |        p0 |       p25 |       p50 |       p75 |       p90 |       p99 |      p100 |   Samples |
|:-----------------------|----------:|----------:|----------:|----------:|----------:|----------:|----------:|----------:|
| Time (user CPU) (ms) * |        80 |        90 |        90 |        90 |        90 |       100 |       100 |        57 |

### app-analytics-services_dot_com_String_Parsing_Malloc

| Metric                 |        p0 |       p25 |       p50 |       p75 |       p90 |       p99 |      p100 |   Samples |
|:-----------------------|----------:|----------:|----------:|----------:|----------:|----------:|----------:|----------:|
| Malloc (total) *       |         4 |         4 |         4 |         4 |         4 |         4 |         4 |        10 |

### google_dot_com_Binary_Parsing_CPU_2M

| Metric                 |        p0 |       p25 |       p50 |       p75 |       p90 |       p99 |      p100 |   Samples |
|:-----------------------|----------:|----------:|----------:|----------:|----------:|----------:|----------:|----------:|
| Time (user CPU) (ms) * |       170 |       170 |       170 |       170 |       170 |       180 |       180 |        30 |

### google_dot_com_Binary_Parsing_Malloc

| Metric                 |        p0 |       p25 |       p50 |       p75 |       p90 |       p99 |      p100 |   Samples |
|:-----------------------|----------:|----------:|----------:|----------:|----------:|----------:|----------:|----------:|
| Malloc (total) *       |         1 |         1 |         1 |         1 |         1 |         1 |         1 |        10 |

### google_dot_com_String_Parsing_CPU_200K

| Metric                 |        p0 |       p25 |       p50 |       p75 |       p90 |       p99 |      p100 |   Samples |
|:-----------------------|----------:|----------:|----------:|----------:|----------:|----------:|----------:|----------:|
| Time (user CPU) (ms) * |        50 |        60 |        60 |        60 |        60 |        70 |        70 |        83 |

### google_dot_com_String_Parsing_Malloc

| Metric                 |        p0 |       p25 |       p50 |       p75 |       p90 |       p99 |      p100 |   Samples |
|:-----------------------|----------:|----------:|----------:|----------:|----------:|----------:|----------:|----------:|
| Malloc (total) *       |         4 |         4 |         4 |         4 |         4 |         4 |         4 |        10 |

## IPAddress

### 111_Machine_Warmup_Benchmark

| Metric                 |        p0 |       p25 |       p50 |       p75 |       p90 |       p99 |      p100 |   Samples |
|:-----------------------|----------:|----------:|----------:|----------:|----------:|----------:|----------:|----------:|
| Time (user CPU) (μs) * |         0 |         0 |         0 |         0 |         0 |     10000 |     10000 |     30916 |

### IPv4_CIDR_Create_Then_Check_Is_Loopback_100M

| Metric                 |        p0 |       p25 |       p50 |       p75 |       p90 |       p99 |      p100 |   Samples |
|:-----------------------|----------:|----------:|----------:|----------:|----------:|----------:|----------:|----------:|
| Time (user CPU) (ms) * |       100 |       110 |       110 |       110 |       120 |       120 |       120 |        45 |

### IPv4_CIDR_Create_Then_Check_Is_Multicast_100M

| Metric                 |        p0 |       p25 |       p50 |       p75 |       p90 |       p99 |      p100 |   Samples |
|:-----------------------|----------:|----------:|----------:|----------:|----------:|----------:|----------:|----------:|
| Time (user CPU) (ms) * |       110 |       110 |       110 |       110 |       120 |       120 |       120 |        45 |

### IPv4_CIDR_Create_Then_Check_Is_Multicast_Malloc

| Metric                 |        p0 |       p25 |       p50 |       p75 |       p90 |       p99 |      p100 |   Samples |
|:-----------------------|----------:|----------:|----------:|----------:|----------:|----------:|----------:|----------:|
| Malloc (total) *       |         0 |         0 |         0 |         0 |         0 |         0 |         0 |        10 |

### IPv4_String_Decoding_Local_Broadcast_10M

| Metric                 |        p0 |       p25 |       p50 |       p75 |       p90 |       p99 |      p100 |   Samples |
|:-----------------------|----------:|----------:|----------:|----------:|----------:|----------:|----------:|----------:|
| Time (user CPU) (ms) * |       180 |       190 |       190 |       190 |       190 |       200 |       200 |        27 |

### IPv4_String_Decoding_Local_Broadcast_Malloc

| Metric                 |        p0 |       p25 |       p50 |       p75 |       p90 |       p99 |      p100 |   Samples |
|:-----------------------|----------:|----------:|----------:|----------:|----------:|----------:|----------:|----------:|
| Malloc (total) *       |         0 |         0 |         0 |         0 |         0 |         0 |         0 |        10 |

### IPv4_String_Decoding_Localhost_10M

| Metric                 |        p0 |       p25 |       p50 |       p75 |       p90 |       p99 |      p100 |   Samples |
|:-----------------------|----------:|----------:|----------:|----------:|----------:|----------:|----------:|----------:|
| Time (user CPU) (ms) * |       150 |       160 |       160 |       160 |       170 |       170 |       170 |        31 |

### IPv4_String_Decoding_Zero_10M

| Metric                 |        p0 |       p25 |       p50 |       p75 |       p90 |       p99 |      p100 |   Samples |
|:-----------------------|----------:|----------:|----------:|----------:|----------:|----------:|----------:|----------:|
| Time (user CPU) (ms) * |       150 |       150 |       150 |       160 |       160 |       160 |       160 |        33 |

### IPv4_String_Encoding_Local_Broadcast_15M

| Metric                 |        p0 |       p25 |       p50 |       p75 |       p90 |       p99 |      p100 |   Samples |
|:-----------------------|----------:|----------:|----------:|----------:|----------:|----------:|----------:|----------:|
| Time (user CPU) (ms) * |       180 |       190 |       190 |       190 |       190 |       200 |       200 |        27 |

### IPv4_String_Encoding_Localhost_15M

| Metric                 |        p0 |       p25 |       p50 |       p75 |       p90 |       p99 |      p100 |   Samples |
|:-----------------------|----------:|----------:|----------:|----------:|----------:|----------:|----------:|----------:|
| Time (user CPU) (ms) * |       170 |       170 |       170 |       180 |       180 |       180 |       180 |        29 |

### IPv4_String_Encoding_Mixed_15M

| Metric                 |        p0 |       p25 |       p50 |       p75 |       p90 |       p99 |      p100 |   Samples |
|:-----------------------|----------:|----------:|----------:|----------:|----------:|----------:|----------:|----------:|
| Time (user CPU) (ms) * |       170 |       170 |       180 |       180 |       180 |       180 |       180 |        29 |

### IPv4_String_Encoding_Mixed_Malloc

| Metric                 |        p0 |       p25 |       p50 |       p75 |       p90 |       p99 |      p100 |   Samples |
|:-----------------------|----------:|----------:|----------:|----------:|----------:|----------:|----------:|----------:|
| Malloc (total) *       |         0 |         0 |         0 |         0 |         0 |         0 |         0 |        10 |

### IPv4_String_Encoding_Zero_15M

| Metric                 |        p0 |       p25 |       p50 |       p75 |       p90 |       p99 |      p100 |   Samples |
|:-----------------------|----------:|----------:|----------:|----------:|----------:|----------:|----------:|----------:|
| Time (user CPU) (ms) * |       170 |       170 |       170 |       180 |       180 |       180 |       180 |        29 |

### IPv6_CIDR_Create_Then_Check_Is_Loopback_100M

| Metric                 |        p0 |       p25 |       p50 |       p75 |       p90 |       p99 |      p100 |   Samples |
|:-----------------------|----------:|----------:|----------:|----------:|----------:|----------:|----------:|----------:|
| Time (user CPU) (ms) * |       110 |       110 |       110 |       110 |       120 |       120 |       120 |        45 |

### IPv6_CIDR_Create_Then_Check_Is_Multicast_100M

| Metric                 |        p0 |       p25 |       p50 |       p75 |       p90 |       p99 |      p100 |   Samples |
|:-----------------------|----------:|----------:|----------:|----------:|----------:|----------:|----------:|----------:|
| Time (user CPU) (ms) * |       110 |       110 |       110 |       110 |       120 |       120 |       120 |        45 |

### IPv6_CIDR_Create_Then_Check_Is_Multicast_Malloc

| Metric                 |        p0 |       p25 |       p50 |       p75 |       p90 |       p99 |      p100 |   Samples |
|:-----------------------|----------:|----------:|----------:|----------:|----------:|----------:|----------:|----------:|
| Malloc (total) *       |         0 |         0 |         0 |         0 |         0 |         0 |         0 |        10 |

### IPv6_String_Decoding_2_Groups_Compressed_At_The_Begining_2M

| Metric                 |        p0 |       p25 |       p50 |       p75 |       p90 |       p99 |      p100 |   Samples |
|:-----------------------|----------:|----------:|----------:|----------:|----------:|----------:|----------:|----------:|
| Time (user CPU) (ms) * |       100 |       100 |       110 |       110 |       110 |       110 |       110 |        47 |

### IPv6_String_Decoding_2_Groups_Compressed_At_The_End_2M

| Metric                 |        p0 |       p25 |       p50 |       p75 |       p90 |       p99 |      p100 |   Samples |
|:-----------------------|----------:|----------:|----------:|----------:|----------:|----------:|----------:|----------:|
| Time (user CPU) (ms) * |        90 |        90 |        90 |       100 |       100 |       100 |       100 |        54 |

### IPv6_String_Decoding_2_Groups_Compressed_In_The_Middle_2M

| Metric                 |        p0 |       p25 |       p50 |       p75 |       p90 |       p99 |      p100 |   Samples |
|:-----------------------|----------:|----------:|----------:|----------:|----------:|----------:|----------:|----------:|
| Time (user CPU) (ms) * |       100 |       100 |       100 |       100 |       110 |       110 |       110 |        49 |

### IPv6_String_Decoding_2_Groups_Compressed_In_The_Middle_Malloc

| Metric                 |        p0 |       p25 |       p50 |       p75 |       p90 |       p99 |      p100 |   Samples |
|:-----------------------|----------:|----------:|----------:|----------:|----------:|----------:|----------:|----------:|
| Malloc (total) *       |         0 |         0 |         0 |         0 |         0 |         0 |         0 |        10 |

### IPv6_String_Decoding_Localhost_Compressed_10M

| Metric                 |        p0 |       p25 |       p50 |       p75 |       p90 |       p99 |      p100 |   Samples |
|:-----------------------|----------:|----------:|----------:|----------:|----------:|----------:|----------:|----------:|
| Time (user CPU) (ms) * |        90 |       100 |       100 |       100 |       100 |       110 |       110 |        51 |

### IPv6_String_Decoding_Uncompressed_2M

| Metric                 |        p0 |       p25 |       p50 |       p75 |       p90 |       p99 |      p100 |   Samples |
|:-----------------------|----------:|----------:|----------:|----------:|----------:|----------:|----------:|----------:|
| Time (user CPU) (ms) * |       120 |       120 |       130 |       130 |       130 |       130 |       130 |        40 |

### IPv6_String_Decoding_Zero_Compressed_10M

| Metric                 |        p0 |       p25 |       p50 |       p75 |       p90 |       p99 |      p100 |   Samples |
|:-----------------------|----------:|----------:|----------:|----------:|----------:|----------:|----------:|----------:|
| Time (user CPU) (ms) * |        80 |        80 |        80 |        90 |        90 |        90 |        90 |        60 |

### IPv6_String_Decoding_Zero_Uncompressed_2M

| Metric                 |        p0 |       p25 |       p50 |       p75 |       p90 |       p99 |      p100 |   Samples |
|:-----------------------|----------:|----------:|----------:|----------:|----------:|----------:|----------:|----------:|
| Time (user CPU) (ms) * |       120 |       120 |       130 |       130 |       130 |       130 |       130 |        40 |

### IPv6_String_Encoding_Localhost_10M

| Metric                 |        p0 |       p25 |       p50 |       p75 |       p90 |       p99 |      p100 |   Samples |
|:-----------------------|----------:|----------:|----------:|----------:|----------:|----------:|----------:|----------:|
| Time (user CPU) (ms) * |       150 |       150 |       150 |       160 |       160 |       160 |       160 |        33 |

### IPv6_String_Encoding_Max_4M

| Metric                 |        p0 |       p25 |       p50 |       p75 |       p90 |       p99 |      p100 |   Samples |
|:-----------------------|----------:|----------:|----------:|----------:|----------:|----------:|----------:|----------:|
| Time (user CPU) (ms) * |       200 |       210 |       210 |       210 |       210 |       220 |       220 |        24 |

### IPv6_String_Encoding_Mixed_4M

| Metric                 |        p0 |       p25 |       p50 |       p75 |       p90 |       p99 |      p100 |   Samples |
|:-----------------------|----------:|----------:|----------:|----------:|----------:|----------:|----------:|----------:|
| Time (user CPU) (ms) * |       180 |       180 |       190 |       190 |       190 |       190 |       190 |        27 |

### IPv6_String_Encoding_Mixed_Malloc

| Metric                 |        p0 |       p25 |       p50 |       p75 |       p90 |       p99 |      p100 |   Samples |
|:-----------------------|----------:|----------:|----------:|----------:|----------:|----------:|----------:|----------:|
| Malloc (total) *       |         1 |         1 |         1 |         1 |         1 |         1 |         1 |        10 |

### IPv6_String_Encoding_Zero_10M

| Metric                 |        p0 |       p25 |       p50 |       p75 |       p90 |       p99 |      p100 |   Samples |
|:-----------------------|----------:|----------:|----------:|----------:|----------:|----------:|----------:|----------:|
| Time (user CPU) (ms) * |       170 |       170 |       180 |       180 |       180 |       180 |       180 |        29 |

Some examples from the results above:
IPv6_String_Decoding_2_Groups_Compressed_In_The_Middle_2M ([2001:0db8:85a3::8a2e:0370:7334]) takes 110ms, meaning ~18 million + rounds in a second.
IPv4_String_Decoding_Local_Broadcast_10M (255.255.255.255) takes 200ms, meaning ~50 million + rounds in a second.

Right now the only thing that is not decently-optimized is the IDNA implementation, which is only used when a domain name is not in simple ASCII.
The implementation passes the whole Unicode 17’s IDNA test suite with 6400 tests, in an extensive way, but I haven’t yet gone for making sure the implementation is optimized.

I don’t expect a package like this to have a massive impact. I’m mostly just hoping for some refined APIs in different packages. For example if a library is taking an argument for an address to another host, It should be able to use these types to make sure an incorrect value is harder to pass to the library, while having better performance in some situations.

So … what do you-all think? Would such a package benefit the community? Would you do this in another way?

13 Likes

I tried something similar for the domain type: GitHub - coenttb/swift-domain-type: A Swift package with a domain-accurate and type-safe Domain model in line with web standards.. Will check out your pay in more detail later when I have time.

2 Likes

Can you do some benchmark using inet_ntop and inet_pton as baseline to compare against?

There is also GitHub - tayloraswift/swift-ip: inline ip address types from @taylorswift

1 Like

That’s an interesting idea. I could add some permanent benchmarks for the inet APIs, just so there is something to compare the library’s benchmarks to.

I have little idea how they’d compare though off-hand.

That’s also an interesting library. I had seen it before but hadn’t taken a deep look at it. I knew it has IP implementations like obvious from the name, and had taken a look at the IP APIs there, but that was pretty much it.

There are some differences between this to-be-library (let’s call it network-address) and swift-ip though.

  • The biggest one is no DomainName support. Which also makes sense since the library is called swift-ip.
  • I struggled a bit to find where the swift-ip tests are and thought maybe somehow someway there are no tests. Anyway, it does look like network-address has a much more complete tests suite. network-address already has a lot of tests for every situation I could think of.
  • Other difference is that swift-ip isn’t yet using the newer APIs like Span, which can result in better performance, unless you’re already using the unsafe pointer APIs. Again, makes sense to some extent, Span APIs are very new.
  • I notice swift-ip has a few dependencies and modules that make it less of a minimal library. If you need any of those modules, then you can’t use network-address. network-address will only contain essential implementations with minimum dependencies.
  • There are also some other subjective differences, e.g. the API surface in swift-ip. One might prefer swift-ip, or network-address.

So all in-all it appears to me that swift-ip was made for usage by the author itself, with less focus on a general audience. I do think it’s working well for the author, but not sure how an external user would feel.

It looks like network-address will be faster than inet APIs in 3 of 4 benchmarks, and do the same amount of mallocs.

Note that this is when calling the inet APIs from Swift. In some situations it won’t make much of a difference, in some other it might. In anycase this is what a Swift user would feel.

I tried to make it a fair battle and make sure we have the least overhead from Swift when using the inet APIs. In the initial benchmarks I put together, the inet APIs were doing worse due to the Swift interaction overhead, and I had to resolve those.
Most of the inet usage is copy pasted from swift-nio.

The benchmarks results are in this GitHub comment.
Click on the ‘Click to expand benchmark result‘ to see the results.
Those who have inet in their name are the inet related benchmarks.
All inet related benchmarks have a counterpart benchmark using the library’s swift APIs.
It’s also worth noting the inet APIs can do more than the Swift APIs do, but not by that much. And again, this what a Swift user would have to do anyway, if they want to do these operations (e.g. parse an ip address from string).

The run-count only applies to the cpu-user-time benchmarks, not mallocs.

15 Millions IPv4_String_Encoding_Mixed
swift: 190ms - 0 mallocs
inet_pton: 1570ms - 0 mallocs

10 Millions IPv4_String_Decoding_Local_Broadcast
swift: 180ms - 0 mallocs
inet_pton: 240ms - 0 mallocs

4 Millions IPv6_String_Encoding_Mixed
swift: 200ms - 1 mallocs
inet_ntop: 1830ms - 1 mallocs

2 Millions IPv6_String_Decoding_2_Groups_Compressed_In_The_Middle_No_Brackets
swift: 110ms - 0 mallocs
inet_ntop: 70ms - 0 mallocs

Good job.

Take a look at inet_ntop source, swift can meet and exceed.

In general the CIDR/IPAddress should be a separate library from DNS library.

Yeah I’d want to see what is the algorithm used there to parse/decode IPv6s … Working with IPv6 String representation is not trivial due to the compression sign (::), so they could be simply using a superior algorithm.

By DNS, did you mean DNS protocol, or Domain Name? Just so I know what you’re exactly talking about.

If you meant the DNS protocol, then I’m happy we’re on the same page, since this whole post is about me trying to see if I should actually go for that.

But if you mean Domain Name, then I’ll have to ask others (e.g. the swift-nio team) to see if they think IP-address stuff should be in a completely separate package, and then the DomainName package can depend on that package if needed, or I can move on with the current plan which is to host these under the same package, but in different targets. With the current plan you still can just not depend on Domain Name if you don’t want to, then you won’t be compiling it either, but it does add some (small?) development overhead since it’ll still need to fetch the Domain Name code which is in the same package, and e.g. fetch swift-nio for Domain Name since it currently uses nio’s ByteBuffer as its storage (Thinking of putting that behind a trait as well. So an IDNA trait, and a NO_NIO_BYTE_BUFFER trait).

1 Like

I got nerd-sniped into pixel-peeping the string-to-ipv6 parsing logic…
I definitely wasn’t needed as what I had written was only ~60% slower, and could do ~18 million rounds per second for “2_Groups_Compressed_In_The_Middle_No_Brackets”,
but the net end result is positive.
The previous implementation used to be faster for short ipv6s, but long story short, worsening the performance of that is a fine compromise to me, if I can make parsing of bigger ipv6 strings get faster. Short ipv6s are still faster to parse than bigger ones anyway, which also makes sense.

I basically copy-pasted the Glibc ipv6 parsing implementation to Swift. The logics still have some differences (e.g. my implementation can parse ipv6 string that are enclosed in brackets, Glibc can’t, or maybe doesn’t want to for some reason (legacy?!)), but they are very close.
In the end Glibc wasn’t doing things too differently in terms of logic.

The final result is:

3 Millions IPv6_String_Decoding_2_Groups_Compressed_In_The_Middle_No_Brackets
swift: 130ms - 0 mallocs
inet_ntop: 100ms - 0 mallocs

so from ~60% slower to ~30% slower than glibc.

1 Like

Where do you think the remainder of the gap is?

I have some ideas but not quite sure. I could take a look at the assembly to tell though. I'd guess it's likely due to the lower level handling of the bytes.

Checking the assemblies was part of the plan, but the whole thing took too long so I let it be.

Like mentioned, my initial implementation was only 60% slower. After that I didnt immediately go for copy pasting the Glibc. I tried a few more implementations out of curiosity, and all were worse than the initial implementation I had put together.

Namely, I tried a first-analyze-then-parse approach, which would first find position of all colons and dots (for ipv4-mpped ipv6s), and only then attempt to parse the string. The positions were stack-allocated in different bytes of some integers. Even then, that turned even worse than my initial implementation. Presumably the cost to iterating the bytes, although minimal, was still too high because in this approach I had to iterate twice over the bytes, once for analyzing and once for parsing.

One other thing that also took a decent amount of time and threw me off was the Swift compiler.

At some point I had some too-long-to-evaluate compiler errors due to some invalid code, so I just moved part of the function into another function. After that the whole thing started to perform 40% better. Apparently Swift was nudged to actually inline and optimize everything, unlike when the whole logic was in 1 function.

1 Like

For the record, Darwin APIs do quite worse than Glibc or my implementation, for some reason:

On my MacBook:

15 Millions IPv4_String_Encoding_Mixed
swift: 153ms - 0 mallocs
inet_pton: 3036ms - 0 mallocs

10 Millions IPv4_String_Decoding_Local_Broadcast
swift: 251ms - 0 mallocs
inet_pton: 468ms - 0 mallocs

4 Millions IPv6_String_Encoding_Mixed
swift: 281ms - 1 mallocs
inet_ntop: 1473ms - 1 mallocs

3 Millions IPv6_String_Decoding_2_Groups_Compressed_In_The_Middle_No_Brackets
swift: 180ms - 0 mallocs
inet_ntop: 360ms - 0 mallocs

2 Likes

I just published this as an standalone package under GitHub - swift-dns/swift-endpoint: a high-performance library containing types representing an endpoint, such as DomainName and IPv4/v6Address .
Chose `swift-endpoint` as the name.
See the README for more info.

There are 2 IDNA_SUPPORT and NIO_BYTE_BUFFER_SUPPORT traits but they don’t do anything yet.
I’ll have to see if they’re worth the hassle.
For example I’ll have to make sure swift-idna does have a noticeable impact at all, so then I can work on the trait to be able to disable the IDNA support. I think IDNA is not needed for most users, unless e.g. they are taking domain names from their users, and they want to make sure they support all cases. Disabling IDNA might help trimming down the binary size for example, for embedded apps.

3 Likes

Some more followup

I made IDNA stuff 5x-13x faster (based on some benchmarks I put together).
Only in cases when the IDNA conversion was not already skipped due to the implementation noticing the input is already all-good or only needs a simple uppercased → lowercased conversion.

In all 3 of swift-idna, swift-endpoint and swift-dns, I also introduced some backwards compatibility stuff so now basically all swift-endpoint and swift-dns APIs are available on macOS 15 as well. In swift-idna, APIs support down to macOS 13.

I think another ~5x improvement is possible in swift-idna. Would need some more backwards-compatibility hacks since UTF8Span is only available on macOS 26, but should technically be doable.

3 Likes

Quick update, swift-idna is now up to 2x faster when the short circuits fail. Details here.

This should mean swift-idna is faster than foundation's ICU wrappers, but I personally haven't run any benchmarks against foundation's ICU wrappers so not 100% sure of the details.

It's been a while, but also swift-idna and swift-endpoint now support down to macOS 10.15+.
swift-dns supports macOS 13+.

2 Likes

It's been a while so let's update / correct this thread's infos one last time, hopefully.
The bold texts are the TL;DRs.

swift-endpoint

As an extended response to @Joannis_Orlandos 's question, I'd say that was essentially "skill issue" as @RandomHashTags likes to say :sweat_smile:.

swift-endpoint now easily beats the C library inet APIs in all cases. Depending on your usage the benefits can be marginal, or 10+x. See swift-endpoint's README Performance section. A lot of time went into putting comprehensive benchmarks together, and the benchmarks are only an under-representation of swift-endpoint's performance against the C APIs for a Swift user.

I can now say that swift-endpoint is overkill in terms of performance, but that comes with no downsides to users of the library. Just that I spent too much time on the library and effectively tried out every single trick I could put together or find on the internet.
It was mostly to satisfy my curiosity and see how far I can get.

Namely, I did go through "Checking the assemblies" (and more, like occasionally checking SIL, although SIL is higher level than assembly but it has its own [dis/]advantages).
I also tried a bunch of SIMD / SWAR / speculative-write tricks. Few of the SIMD / SWAR tricks made it into the library to make it as fast as it is. Speculative writes were usually a win. For IPv4 such tricks won't pay off since IPv4 length is too small, but for IPv6 they did. For example as a result the IPv6 to-string serialization is not only faster, but also essentially branchless.

swift-idna

See swift-idna's README Performance section. We now have benchmarks to prove that swift-idna does beat ICU.

This is also only made possible by a few more "tricks". For example, and also just off the top of my head, I had to put together a "stack-or-spill-to-heap" "Small/TinyArray" type and I hope I can properly decouple it from the project to swift-tiny-sequence.
I think I can do a few more optimizations in swift-idna as well but not sure if I'll find the time.

What happened to the "standard network address types" idea?

See Announcing the Networking Workgroup, the new Networking Workgroup is interested in the idea so we'll see where we'll get with this.

6 Likes

Given your own time, there's really no such thing for such a fundamental data type. Personally, I'd be interested in summaries of the approaches you took and what their impact was, as I'm developing a sense of what's fast in Swift and what's not.

I also appreciate your approach to benchmarks, it's definitely a focus to carry forward.

2 Likes

I don't think it's possible to mention nearly everything both because it's hard to re-remember and also there were way too many things that I had to learn or try.
However, I think I have developed a fine (but not complete) such sense of what's fast or not, in Swift or otherwise.

I'm even thinking given all the work it took, I now have a base for writing a whole book about Swift performance. If such a book ever comes to exist, it'll of course take much more effort to research and to perhaps gather data to show in the book since I'd want it to be quantitive, and of course it'll be an end-to-end book mentioning many more stuff than just such micro optimizations (multi-threading, algorithms, stack vs heap, system calls, atomics/locks, async-sequences, table/dict lookups, String itself, ARC/CoW/..., SIMD/SWAR/branchless code, and even more).

All those being said, here are some tips I can mention for now for lower-level optimizations.
Also to be clear, I'd assume you, Jon, know part of what I'm going to mention, but I'm posting in a public place so more people will read this, and also I don't know you closely so I'm just going to not make too many assumptions :slightly_smiling_face:.

[Edit: Now that I've written this text, I notice it has only scratched the surface and I've already written a long post. And probably didn't even answer your question properly. I'll be happy to answer any further questions, but as you can see the situation is more complicated than me being able to summarize them all in simple tips]

Prerequisites

I think the most important note is that for micro optimizations you need tests. This is a requirement to not end up with optimized code which crashes half the time has incorrect outputs.
An exhaustive amount of tests. Not just unit tests, but integration tests if they apply, and of course, performance tests (benchmarks).

Without such tests, it's waaay too risky to mess around with the code. If you're not willing to have a lot of tests or don't have the time, then try to instead write readable code instead of micro-optimized code.

Again, some of these points might seem obvious to some people. I'm going to just mention them all anyway.
A lot of these points are not necessarily Swift related. They can be generalized to a lot of languages which use LLVM beneath, or they are CPU related, not language.

Tips

Benchmarking

  • Have heap-allocation benchmarks: they are pretty easy to have and don't require a dedicated-cpu or bare-metal machine.
  • Instruction-count benchs are also good to have but beware instruction count doesn't necessarily mean speed.
  • cpu-user time (or other timing benchmarks, for example wall-clock) probably need you to rent a machine somewhere. GitHub Actions machines are unreliable for this. AWS EC2 instances are not too bad. Optimally you should have a dedicated core machine, or even better bare metal. These are in order of price, and I've found dedicated-core VMs to be just as good as the bare metal ones for single threaded benchmarks.
    • Usually the best you can get is cpu-user, to have minimal noise, but there are caveats. For example If you want to count the time when your process is waiting on system (e.g. IO / Locks) you'll have to choose something other than cpu-user.
    • Still there will be noise of 10% at least (unless e.g. all your code is branchless which is unlikely), so you'll need some process to be able to properly maintain your benchmarks etc... . This took a lot of effort to set up for me. I currently maintain a fork of package-benchmark that fits my (and I believe other people's) needs and makes maintaining timing benchmarks bearable.
  • And you might want to find and benchmark your code in other meaningful ways as well.

Setup

  • If you want to micro optimize, you should understand a bit of assembly at least. Read assembly outputs of the compiler. You can use online tools like Godbolt, or use swiftc's -emit-assembly flag (you can build around that. For example ask an LLM to build a script which builds and shows you the assembly of a specific function in a specific module. Beware of code inlining in that case). Prefer arm, not x86_64.

  • LLVM (and the compiler) are pretty smart. You'll need to develop a sense of LLVM's smartness.

    • A classic example where LLVM skips doing a division which are much more expensive than multiplications/bitshifts:

    extension UInt8 {
    func ...() {
    /// The compiler is smart enough to not actually do division by 10, but instead use the
    /// multiply-by-205-then-bitshift-by-11 trick.
    /// See it for yourself: godbolt
    let (q, r1) = self.quotientAndRemainder(dividingBy: 10)

    • LLVM knows lots of these "bit hacks".
    • See Bit Twiddling Hacks for example. Or checkout the book "Hacker's Delight" from 2002. LLVM knows when to use a lot of these. But also can't tell when a lot more are applicable.
  • If you have the choice, prefer bitshifts over loading from memory (e.g. myUInt >> 8 & 0xFF vs withUnsafeBytes(of: myUInt) { $0[1] }). LLVM can optimize a lot of these when used in non-trivial contexts. It's more clear what a bit shift is trying to do compared to a mem load.

  • One nice optimization I've noticed is when LLVM can remove the preconditions that are done before accessing elements of collections. Most people are aware of this as this is advertised a bunch in different videos, but not sure if all users realize how far the optimizations can go.

    • For example if you check guard span.count > 10 else { return }, a span[5] after that will not compile with the index-out-of-bounds precondition.
  • Generally speaking sometimes you might need to use the unchecked accessors (span[unchecked: num]) if you're using span instead of unsafe pointers which do unchecked access in release builds anyway.

    • Each element access can (not always does) come with a branching. The branching itself is a minor issue as the Swift compiler will mark the crashing branch as improbable so CPUs won't care about it much, but it can mess up some cases (see SIMD below).
  • Generally speaking at this point you should have a fine understanding of CPU behaviors as well as the techniques they [can] use. Namely instruction/data-level parallelization of the CPUs, or their typical cpu-level caching layers/behaviors.

    • About data-level parallelization (vectors, SIMD/SWAR), you can use SWAR just fine in Swift since it's "SIMD in a register" and in most devices these days "a register" means 64 bits aka just "[U]Int64".
    • For SIMD, Swift's SIMD support is weak, but that shouldn't generally block you. You are limited but not by a lot. You need to get LLVM to realize the possibility of using SIMD, and it's not that hard to do. Some requirements for such LLVM auto-vectorization are: use a constant-count (not needed to be known at compile time) loop. Make sure the loop doesn't contain any branches (there are caveats but most of the times).
    • For example this code will compile to SIMD to check if a sequence of utf8 bytes are in ASCII or not (are less than 128, which means they all have their most significant bit unset). Note that self[unchecked: idx] would have been required if LLVM wasn't able to elide the "index-out-of-bounds" precondition that comes with self[idx]:
extension Span<UInt8> {
    var isASCII: Bool {
        var result: UInt8 = 0
        for idx in self.indices {
            result |= self[idx]
        }
        return result <= 127
    }
}
  • For SWAR, they are usually just a bunch of smart and complicated bit tricks [swift-endpoint example]. There is not much of a restriction for Swift specifically since you're just doing bit work. Only that you'll likely need to use unchecked operators (e.g. << -> &<<) to skip the branching/slowness that comes with the checked operators.
    • The idea is that today's CPUs usually have general purpose registers of size 64 (aka 64-bit architecture). That means they do most of their work on 64 bits regardless of whether you're working with [U]Int8 or [U]Int64 etc..., so what you can do is to cramp 8 UInt8s or 16 half-bytes (nibbles) into a UInt64, and instead of doing like 2-3+ instructions on each of those 8/16 bytes (16 - 48 instructions), do like 5-8 operations on one UInt64 (5 - 8 instructions total).
  • For 16 <= bytes SIMD is not worth it. Even for 32 it's questionable. LLVM also knows these.
  • For SWAR the job is much simpler since you can do a rough count of instructions instead of having to take into account how a CPU's SIMD register cost/performance behavior looks like. Also there are SIMD differences between CPUs (e.g. size of the SIMD registers, 128/256/512) but for SWAR, effectively it's usually 64 and sometimes 32 and that's it for the most part.
  • Generally speaking, I was curious to know how Swift compares against C/Rust as well, so I also did a bunch of benchmarks of the low-level primitives they provide, and for the most part there was no difference between C/Swift/Rust so that might be good news to everybody.
  • For instruction-level parallelization, it's again something CPUs do so you need have an idea how CPUs like to work. For example the fact that if you have a loop (or even not a loop) and each round is sufficiently unrelated to the previous round (e.g. there is no sequential dependency) then today's CPUs will silently run multiple rounds in parallel to achieve maximum performance and fill in the gaps where e.g. they need to wait for a read from DRAM which is considered slow at CPU scale.
    Essentially CPU are way more than just a simple execution unit and they do a lot of smart things and optimizations behind the scenes and you'll have to try to exploit their behaviors. This can get very CPU specific, but there are also a lot of general behaviors that all CPUs have in common.
  • One more thing is that try not to write code that produces branch in the final assembly. Essentially all ifs that are not trivial. For example a number compare is trivial: var myVal = isTrue ? 1 : 0 won't be a branch, it'll be a simple compare.
  • And then there are a bunch of caveats here. For example a branch that is unused in most cases, can likely be quickly excluded from most calculations by the CPU so it's not as costly. Or if you manually mark a path as unlikely (Swift's _slowPath/_fastPath, used in asserts, preconditions and a lot more places).
  • On the other hand a branch that can go either way will mess up the CPU's calculations. The CPU will try to predict the branch which will be hit and do the work for it upfront/in-parallel, and will frequently be wrong about it, which means wasted CPU resources / cache misses etc....
  • I have yet to even talk about CPU cache optimizations and cache locality. Or about speculative writes. Which basically means that the CPU caches stuff block-by-block. If it needs 1 byte, it'll likely pull and cache 8/16 bytes instead (in the L1 cache; size is cpu dependent) so if you write code in such a way to exploit the fact that the next bytes are likely cached in the L1 cache, your code will end up faster.
  • About speculative writes, the idea is that for writing bytes, it's usually faster to do buffer[index] = myByte; index &+= shouldWriteMyByte ? 1 : 0 than if shouldWriteMyByte { buffer[index] = myByte; index &+= 1 }. The latter comes with a branch which the former does not even considering it's supposed to write to a place. On a CPU level, CPUs have write buffers as well so that's one of the reasons why this happens: the CPU isn't actually writing any data to DRAM until it knows it has to.

A nice talk about some the points above is this one: https://www.youtube.com/watch?v=vd9J9PPmAMM

6 Likes

Mahdi, thank you for taking the time to write this. Your emphasis on exhaustive correctness tests, representative benchmarks, and inspecting optimized assembly matches what I have been learning while working on swift-cidr.

One point I would add is that source code which appears lower-level or more clever is not necessarily faster. The compiler and the measured workload get the final vote.

One concrete example: IPv4 formatting

IPv4 octets are unusually well suited to a lookup table because there are only 256 possible values. The production swift-cidr formatter stores every octet as a three-byte UTF-8 triplet:

000 001 002 ... 042 ... 186 ... 255

The writer handles:

  • 0...9 with one ASCII adjustment and store, without accessing the table.
  • 10...99 by copying the final two bytes of the triplet.
  • 100...255 by copying all three bytes.

The complete table is 768 bytes, or approximately twelve 64-byte cache lines. Once warm, it is a very small L1-resident lookup.

We tried several alternatives:

  • arithmetic using quotient and remainder
  • hybrid table/arithmetic dispatch
  • SWAR digit-width classification
  • speculative writes
  • homogeneous-width fast paths
  • raw-pointer substitutions
  • a prefix-aware writer exploiting zero host octets

Some candidates won on narrow artificial distributions, but none remained faster across mixed addresses, BGP prefixes, RPKI prefixes, non-octet-aligned networks, and /32 routes.

The prefix-aware experiment was particularly instructive. It added a branch to avoid formatting a trailing zero that the existing writer already handled with one comparison, one ASCII addition, and one store. The candidate increased the hot function from roughly 772 to 1,100 bytes, measured 7.2% slower on the BGP corpus, and regressed /32 routes by as much as 18.4%.

The general table writer was not always the absolute winner for every synthetic input. It was instead the low-regret solution: compact code, bounded memory, predictable work, and no dependency on a particular address distribution.

That does not conflict with your observation that speculative writes and SWAR paid off for IPv6 but not IPv4. IPv6 provides enough work to amortize classification and setup. Four IPv4 octets often do not.

Why this matters at infrastructure scale

This work is not only about reducing a microbenchmark by a few nanoseconds. The ARIN rpki-client JSON snapshot used for these experiments contained 182,790 IPv4 VRP records. That represents one RIR-derived dataset; a global validator processes the combined output from multiple trust anchors. Each VRP carries an IP prefix, prefix length, maximum length, and origin AS number, as defined by RFC 6811.

A routing or validation system may repeatedly parse, canonicalize, compare, validate, diff, and export that entire dataset. BGP routing tables can be larger still.

At those volumes, semantic precision and performance are both mandatory:

  • Address family and prefix boundaries must remain explicit.
  • Networks must be canonicalized consistently.
  • Prefix and maximum-length constraints must remain valid.
  • Bulk processing cannot introduce unnecessary allocation or formatting overhead.

A fast representation that weakens network semantics is unsafe. A semantically precise representation that cannot efficiently process control-plane volumes is also incomplete.

This is the practical reason I have continued measuring both typed public APIs and direct UTF-8 bulk paths in swift-cidr: they serve different workflows, but both need to remain correct and predictable.

Benchmark design was part of the problem

I also encountered the fixed-input optimization trap. A benchmark repeatedly formatting one constant address allowed the compiler to specialize more than expected. Moving to a deterministic RNG and a runtime-selected 16-address corpus produced a more defensible workload.

We now measure separately:

  • selection, indexing, and blackHole overhead
  • public String formatting
  • direct UTF-8 writing into caller-owned buffers

Other lessons matched your findings:

  • / and % in source do not imply division instructions after optimization.
  • &>> is not automatically faster than >>; context and generated assembly matter.
  • Raw pointers are not automatically faster when the compiler already removes bounds checks.
  • Branchless code is a means, not a goal.
  • Short-lived Git branches and worktrees are useful for preserving failed experiments without carrying them into production.
  • AI is a considerable force multiplier for generating candidates, tests, benchmark scaffolding, and assembly tooling, but it also produces plausible-looking optimizations that regress performance.
    Measurements remain the authority.

The broader lesson for me has been the same one I take from your post: performance engineering is an empirical collaboration with the compiler and CPU, not a contest to write the cleverest-looking source.

For anyone interested in the resulting measurements, the current swift-cidr performance snapshot separates public API wall-clock latency from fixed-loop bulk CPU throughput and includes the corresponding POSIX baselines.

3 Likes