Repository navigation
Format JSON timestamps without DateTime - #451
Conversation
Encoding a Google.Protobuf.Timestamp to JSON went through DateTime.from_unix/2, two DateTime.compare/2 range checks, DateTime.to_iso8601/2 and a String.split_at/2 to splice in the fraction. In a JSON-heavy service this formatter showed up as more than half of Protobuf.JSON.Encode's CPU time. Check the allowed range (0001-01-01 to 9999-12-31) on the integer seconds, split them with :calendar.gregorian_seconds_to_datetime/1 and write the fixed-width digits straight into a binary. The fraction keeps the 3, 6 or 9 digit rendering the proto3 JSON mapping asks for. Inputs outside the range still go through DateTime.from_unix/2, so the error tuples are unchanged. The tests keep the old DateTime-based encoder as a reference and compare the two byte for byte on edge dates and 200k random timestamps across the whole range. The new encoder is about 4-6x faster.
whatyouhide
left a comment
There was a problem hiding this comment.
Thank you! Left some comments.
|
@whatyouhide thanks for the review! I added a new commit incorporating your feedback. One note - I added the property based test (with max_runs: 10k) and left the looping one (200k) in place. They check the same thing, though, so we could drop the latter and bump the former's max_runs up to compensate, happy to adjust however you prefer. |
|
@bruckner let's go with just the property-based test and 10k runs is already whopping, we can probably reduce that and potentially scale the generator more aggressively, up to you and depending on how long that test takes. |
e772ec4 to
c5d55d6
Compare
Spell the allowed range and the Unix epoch as DateTime and :calendar literals evaluated at compile time, so the guard reads as the dates it checks rather than as magic numbers, and write the range check with `in`. The byte-identity check against the DateTime-based formatter becomes a stream_data property (5k runs) in place of the hand-rolled random sample, so a mismatch shrinks to a minimal example.
c5d55d6 to
9869070
Compare
|
@whatyouhide sounds good, I dropped the loop test and reduced max_runs to 5k - runs in ~10ms on my box |
|
Thank you @bruckner 🫶 |
Motivation
I profiled a Phoenix JSON API that encodes proto messages with
Protobuf.JSON(protobuf 0.17.0), running under load (500 simulated users,perf recordwith+JPperf true, 60 s, ~32k samples).Protobuf.JSON.Encode.encodable/2took 13.6% of BEAM samples, and 7.5% were insideProtobuf.JSON.RFC3339.encode/2. That is more than half of JSON encoding time spent formattingGoogle.Protobuf.Timestampvalues. The encoder goes throughDateTime.from_unix/2, twoDateTime.compare/2range checks,DateTime.to_iso8601/2, and aString.split_at/2to splice in the fractional seconds.Note: This is independent of #449, which speeds up the binary encoder; this is the JSON path.
What changed
RFC3339.encode/2now:-62_135_596_800..253_402_300_799) with integer comparisons in the function guard;:calendar.gregorian_seconds_to_datetime/1;Utils.format_nanoseconds/1gave.Inputs outside the range still go through
DateTime.from_unix/2, so the error tuples don't change. You still get"timestamp is outside of allowed range"for years -9999..0000 and":invalid_unix_time"beyond that. Thenanos >= 1_000_000_000clause is untouched.decode/1is untouched too;datetime_in_allowed_range?/1andUtilsare still used there.Byte-identity test
test/protobuf/json/rfc3339_test.exskeeps the old DateTime-based encoder as a privatereference_encode/2and assertsRFC3339.encode/2 == reference_encode/2in two places:A third test checks the out-of-range error tuples against the reference, just below the minimum, just above the maximum, and at and below
Calendar.ISO's lower bound. The full suite passes (568 tests, 33 properties, 33 doctests, 0 failures) on Elixir 1.19.5 / OTP 28. Changing the new formatter makes the comparison tests fail.Benchmark
:timer.tcover 100k{seconds, nanos}pairs,MIX_ENV=prod, Elixir 1.19.5 / OTP 28, Apple Silicon. Both runs had a warm-up. Old means the v0.17.0 /mainmodule compiled under another name.Support floor
The code uses only
:calendar.gregorian_seconds_to_datetime/1, binary construction and integer arithmetic, all of which are far older than the library's floor (elixir: "~> 1.16"; CI goes down to Elixir 1.16.3 / OTP 26.0). The tests use~Usigils,DateTimeandEnum.random/1on ranges, which are also available on 1.16.