Parse3339: Faster parsing with fixed locations and number packing

I maintain a Swift library for parsing (a subset of) RFC 3339 time stamps, Parse3339. It's always been pretty fast, thanks to restricting the supported formats and using straightforward Swift that operates on UTF-8 bytes. I've known it has left some performance on the table compared to parsers that use SIMD tricks, but I've opted to keep it simple.

Then I saw A Very Fast IPv4 Parser, in Swift. In it, the author describes an IPv4 address parser that achieves very respectable performance using two techniques: parsing things from known positions, and SWAR, meaning "dealing with many small numbers by packing them into a UInt64". I got nerd sniped.

I applied similar optimizations to Parse3339: reading from fixed positions instead of using a state machine, and applying SWAR to improve parallelism. The following table shows the p50 and p90 execution times of a benchmark parsing a Span<UInt8> using 100000 samples. The environment is a MacBook Pro M5, macOS 27.2, Swift 6.4.2.

Implementationp50 (ns)p90 (ns)
State machine3939
Fixed positions1212
SWAR89

I'm going to go over the changes in this post because I found the whole process pretty enlightening. I hadn't realized how much the fixed position read would win, and the SWAR thing was all new territory for me.

Fixed Positions

The formats Parse3339 supports are very simple: 2026-12-29T13:14:15, followed by an optional fractional second (.12) and a mandatory time zone (Z or -03:45). That makes the first change, parsing from known positions, stupidly simple. So we won't run a state machine; instead we'll just parse bytes at offsets five and six and know they'll be the month. And because it's just a bunch of reads one after another without branching between them, the processor can perform at least some of them in parallel.

The change where I replaced the state machine reduced the number of lines in the parser by 176 and shaved a huge chunk off the execution time (see the table above). Losing a good state machine is always a bit sad but I'll live.

SWAR

SWAR means SIMD Within A Register. I bet the parties responsible are GWAR fans.

Anyway, despite the "SIMD" in there, all this code does is it just splits the input into 8-byte chunks, packs those chunks inside larger integers and then operates on the big boys. More or less. Then the compiler and the processor will do their magic and things will be faster than ever, without me actually having to use SIMD operations myself. The price is readability. In this code, each byte in a UInt64 acts as a "lane", a separate value, and then you operate on the lanes using honking big masks consisting of 8 hex digit pairs (again, UInt64) and things look a bit hairy thanks to that.

I can't boast about the second change shrinking the code or making it easier to understand, but the execution time win was respectable enough.

Let's walk through the changes.

Unsafe Loads

After the code has checked that there are enough bytes in the input byte span, it reads three values:

let date = span.bytes.unsafeLoadUnaligned(as: UInt64.self).littleEndian
let time = span.bytes.unsafeLoadUnaligned(fromByteOffset: 8, as: UInt64.self).littleEndian
let tail = span.bytes.unsafeLoadUnaligned(fromByteOffset: 16, as: UInt32.self).littleEndian

unsafe on each line because the earlier length checks allows us to skip range checks, Unaligned because we're reading without requiring byte alignment, littleEndian to ensure byte 0 of the string goes into the low 8 bits of the integer. That's a no-op on Apple platforms, but on big endian platforms it means a byte swap.

So if the time stamp is 2024-10-28T13:14:15Z, date gets 2024-10-, time gets 28T13:14, and tail gets :15Z (tail is a UInt32, i.e. 4 bytes.)

The little-endianness means the first input byte appears as the least significant byte, and the bytes appear in reverse string order when represented in hexadecimal. For 2024-10-:

chunk byte index76543210
character-01-4202
date (hex)2D30312D34323032

For 28T13:14:

chunk byte index76543210
character41:31T82
time (hex)34313A3331543832

And for :15Z:

chunk byte index3210
characterZ51:
tail (hex)5A35313A

Separator Check

Next we get to this bit:

guard date & 0xFF_00_00_FF_00_00_00_00 == 0x2D_00_00_2D_00_00_00_00,
      time & 0x00_00_FF_00_00_FF_00_00 == 0x00_00_3A_00_00_54_00_00,
      tail & 0xFF == 0x3A
else { return nil }

Each line uses an AND (&) operation, masking out everything but the separator lanes. Then the comparison checks that the separators are what they should be, all at the same time:

HexCharacter
2D-
3A:
54T

Again, remember that the UInts contain the string bytes in reverse order, that's why the time line has 3A before 54.

Value Normalization

Next is a XOR thing for converting separators to zero and ASCII digits to numbers 0–9:

let dateDigits = date ^ 0x2D_30_30_2D_30_30_30_30
let timeDigits = time ^ 0x30_30_3A_30_30_54_30_30
let secondDigits = UInt64((tail >> 8) & 0xFFFF) ^ 0x3030

ASCII '0'...'9' is 0x30...0x39. x ^ 0x30 changes that value range to 0x00...0x09. And because the code already checked the separators, we can zero them out at the same time: 0x2D ^ 0x2D is 0. So if date is 0x2D_30_31_2D_34_32_30_32, dateDigits is 0x00_00_01_00_04_02_00_02. timeDigits works the same.

secondDigits is different because tail is the UInt32 value. Let's start with 0x5A_35_31_3A from the earlier example. The innermost bit shift discards the rightmost byte, the one for :, so we're left with 0x5A_35_31. Then the AND takes the two rightmost bytes, the ones representing the seconds value, leaving us with 0x35_31. And then the XOR returns 0x05_01.

Digit Validation

The next step is digit validation, which again happens all at the same time:

let invalidDigits = ((dateDigits &+ 0x76_76_76_76_76_76_76_76) | dateDigits)
    | ((timeDigits &+ 0x76_76_76_76_76_76_76_76) | timeDigits)
    | ((secondDigits &+ 0x7676) | secondDigits)
guard invalidDigits & 0x80_80_80_80_80_80_80_80 == 0 else { return nil }

What this does is it checks that every byte is less than 10, and does that check for all of them at the same time. This happens by setting the highest bit on every invalid byte to 1. UInt64 is 8 bytes, and each byte is 8 bits: 0b00_00_00_00 … 0b11_11_11_11.

If we unpack that invalidDigits definition a bit, every line is the same: ((value &+ 0x76_76…76) | value). The results of every line is ORred together, and the result is ANDed with (0x80_80…80) and that result is required to be zero.

Starting from the most nested level of each line: &+ is Swift's wrapping addition operator. Normally an overflow traps; &+ makes it wrap, so (255 as UInt8) &+ 1 is zero. What's up with the 0x76 (118, 0b01_11_01_10)? If you add it to 0x09, you get 0x7F, or in binary, 0b01_11_11_11. If you add it to 0x0a (10), you get 0x80, 0b10_00_00_00. The step before this normalized all digits — values from 0 to 9 — to be values from 0x00 to 0x09. This means that every value less than or equal to 9 still gets a 0 as the highest bit. Larger values get a 1, either in the addition step or in the subsequent OR step which ensures that if the original had a 1 as the highest bit but the addition wrapped it away, it will still be 1.

value&+ 0x76OR value
0x000b01_11_01_100b01_11_01_10
0x01 … 0x090b0X_XX_XX_XX0b0Y_YY_YY_YY
0x0a … 0x7f0b1X_XX_XX_XX0b1Y_YY_YY_YY
0x80 … 0x890b1X_XX_XX_XX0b1Y_YY_YY_YY
0x8a0b00_00_00_000b10_00_10_10
0x8b … 0xff0b0X_XX_XX_XX0b1Y_YY_YY_YY

The addition will carry between lanes if the value is large enough, but that's OK: it'll just cause us to reject the output, which is the result we wanted.

After we've put dateDigits, timeDigits and secondDigits through that — and remember, they're all UInt64s — we OR all of them together. Meaning that every bit that is 1 in one of the values will end up as 1 in invalidDigits.

Then the guard line ANDs all the lanes in invalidDigits with 0x80, i.e. 0b10_00_00_00. If any lane has the high bit set, it'll end up as 0x80 in the result, the equality check won't end up as zero and the guard will return nil, all with one branch.

Read the Numbers

After the complexity of validation, operating on those validated values is easier:

let year = decodeTwoDigits(dateDigits) * 100 + decodeTwoDigits(dateDigits >> 16)
let month = decodeTwoDigits(dateDigits >> 40)
let day = decodeTwoDigits(timeDigits)
let hour = decodeTwoDigits(timeDigits >> 24)
let minute = decodeTwoDigits(timeDigits >> 48)
let second = decodeTwoDigits(secondDigits)

/* … */

private func decodeTwoDigits(_ digits: UInt64) -> Int {
    Int(digits & 0xFF) * 10 + Int((digits >> 8) & 0xFF)
}

We already know that the UInt64s contain numbers and separators in known positions. dateDigits, from the example 2024-10-, has been reversed, has had its separators converted to zeroes and its numbers normalized, so is 0x00_00_01_00_04_02_00_02. decodeTwoDigits decodes the two least significant bytes of its input as a number. The calling code just has to shift the input enough to the right to get the relevant bytes in the correct position. So the values are:

let year = decodeTwoDigits(dateDigits) * 100 + decodeTwoDigits(dateDigits >> 16)
//       = 20                          * 100 + 24
//       = 2024
let month = decodeTwoDigits(dateDigits >> 40)
//        = 10

And the same for timeDigits and secondDigits.

Zone Reading and Validation

After the time values have been read, there's calendar validation, then fractional second handling, and then time zone validation. The first option for time zone is Z, which doesn't require any work. A ±hours:minutes style (e.g. -02:30) zone requires more work, which uses the same tricks as the above, only on a smaller scale:

// direction is UInt8 with ASCII value for + or -, index is pointing at its position in span.
let zoneHours = span.bytes.unsafeLoadUnaligned(fromByteOffset: index + 1, as: UInt16.self).littleEndian
let zoneMinutes = span.bytes.unsafeLoadUnaligned(fromByteOffset: index + 4, as: UInt16.self).littleEndian

Now, for the -02:30 input, zoneHours is 0x32_30 (ASCII two, ASCII zero), zoneMinutes is 0x30_33 (ASCII zero, ASCII three). Both values are UInt16, because each holds two bytes.

let zoneDigits = (UInt32(zoneHours) | (UInt32(zoneMinutes) << 16)) ^ 0x30_30_30_30

zoneDigits combines those values into one UInt32, with hours as the least significant bits, and digits normalized to bytes 0x00–0x09. We can insert the above values into that zoneDigits definition expression, step by step:

ExpressionOutput
UInt32(0x30_33) << 160x30_33_00_00
UInt32(0x32_30) | 0x30_33_00_000x30_33_32_30
0x30_33_32_30 ^ 0x30_30_30_300x00_03_02_00

0x00_03_02_00 is the 02:30 time zone offset, reversed and hexified.

guard ((zoneDigits &+ 0x76_76_76_76) | zoneDigits) & 0x80_80_80_80 == 0 else { return nil }

Next it's our old friends 0x76 and 0x80 again, because now we're validating the lanes in the UInt32. The innermost addition adds 0x76 to each lane, then we OR with the original value to ensure that any lane that is not in the range 0x00–0x09 gets its highest bit set, and then we AND with 0x80 to check that every lane has zero as its highest bit.

After that it's just decoding digits, validating the numbers and calculating the offset in minutes and negating the value if the time zone direction symbol was - (or producing a nil offset, with -00:00). Then we return the parsed time stamp.

32-bit vs 64-bit

Embedded Swift and older computers are a thing and honestly I don't know if this is a good change for people on those platforms. If it turns out to be a problem, I recommend sticking to (or forking) 0.10.0, the version before the SWAR changes. Or if you email me telling me you're using this on such a platform and the changes are a problem, I'll consider putting the old version behind a trait.

© Juri Pakaste 2026