Skip to content

perf(redis): keep JSON serialization on UTF-8 bytes instead of UTF-16 strings - #88

Merged
cosmin-staicu merged 1 commit into
mainfrom
perf/utf8-serializer
Jul 17, 2026
Merged

perf(redis): keep JSON serialization on UTF-8 bytes instead of UTF-16 strings#88
cosmin-staicu merged 1 commit into
mainfrom
perf/utf8-serializer

Conversation

@cosmin-staicu

Copy link
Copy Markdown
Member

Summary

JSON is UTF-8 on the wire, but the cache serializer round-tripped every payload through a UTF-16 string, adding an allocation + transcode on every read and write. This keeps serialization on UTF-8 bytes end-to-end, enabled by StackExchange.Redis 3.0's RedisValueReadOnlyMemory/ReadOnlySequence<byte> conversions. No public API change beyond two [Obsolete] markers; wire format is unchanged so existing cached/published data still deserializes.

Stacked on #86 (needs SE.Redis 3.0). Merge #85#86 first; this retargets to main afterward.

Changes

  • SystemJsonSerializerProxy (the default proxy, shared by RedisCache, RedisHashCache, RedisSetCache, RedisCacheProvider — all inherit the win): serialize via SerializeToUtf8Bytes; deserialize from the RedisValue payload as ReadOnlySpan<byte>; deserialize a JsonElement directly instead of via GetRawText().
  • Broadcast: CacheEventFormatter.Encode uses SerializeToUtf8Bytes; the publish paths (RedisPubSubTopic, RedisStreamsTopic) publish the encoded bytes as a RedisValue directly; the subscribe paths (RedisPubSubSubjectWriter, RedisStreamSubjectWriter) decode from the RedisValue bytes.
  • Deprecated: IEventFormatterProxy<T>.Decode(string) and EncodeAsString(T) (now unused by the library — they forced a UTF-16 transcode). Stream-subject-writer tests migrated to the ReadOnlyMemory<byte> overload.

Benchmark

New SerializerBenchmark ([MemoryDiagnoser]). Allocation numbers are exact/reliable; roughly halved on both paths:

Case string (baseline) bytes
Deserialize Large 1,385,629 B 607,714 B (0.44×)
Deserialize Medium 3,368 B 1,736 B (0.52×)
Serialize Large 778,689 B 389,660 B (0.50×)
Serialize Medium 1,944 B 1,144 B (0.59×)

The benchmark also measured a ReadOnlySequence<byte>/Utf8JsonReader read variant; it allocated identically to the span path but ran slower on these single-segment payloads, so the span path was chosen. (Run: dotnet run -c Release --framework net10.0 --project benchmarks/UiPath.Caching.Benchmarks -- --filter *SerializerBenchmark*)

Test plan

  • Unit tests added/updated (SystemJsonSerializerProxyTests incl. legacy string-path round-trip; broadcast tests migrated)
  • Integration tests pass locally (dotnet test)
  • CHANGELOG.md updated

Full suite green on net8.0 and net10.01178/1178 each — with RUN_REDIS_INTEGRATION_TESTS=1 against a live Redis.

Linked issues

Fixes #

Contributor declaration

  • I signed off my commits per the DCO (git commit -s).
  • I am contributing on behalf of my employer, or in the course of employment / using employer resources.

🤖 Generated with Claude Code

@cosmin-staicu
cosmin-staicu force-pushed the chore/stackexchange-redis-3 branch from 0145220 to d103e81 Compare July 17, 2026 09:26
Base automatically changed from chore/stackexchange-redis-3 to main July 17, 2026 09:35
@cosmin-staicu
cosmin-staicu force-pushed the perf/utf8-serializer branch from 8e0cfcc to 0a98e9f Compare July 17, 2026 09:36
… strings

JSON is UTF-8 on the wire, so round-tripping cache payloads through a UTF-16
string only added an allocation + transcode on every read and write. This
moves the serialization boundaries to bytes (enabled by StackExchange.Redis
3.0's RedisValue <-> ReadOnlyMemory/ReadOnlySequence<byte> conversions).

- SystemJsonSerializerProxy: serialize via SerializeToUtf8Bytes; deserialize
  from the RedisValue payload as ReadOnlySpan<byte>. Also deserialize a
  JsonElement directly instead of via GetRawText(). Shared by RedisCache,
  RedisHashCache, RedisSetCache, RedisCacheProvider, so all inherit the win.
- Broadcast: CacheEventFormatter.Encode uses SerializeToUtf8Bytes; the Redis
  publish paths (RedisPubSubTopic, RedisStreamsTopic) publish the encoded bytes
  as a RedisValue directly; the subscribe paths (RedisPubSubSubjectWriter,
  RedisStreamSubjectWriter) decode from the RedisValue bytes.
- Deprecate the now-unused string helpers IEventFormatterProxy<T>.Decode(string)
  and EncodeAsString(T); migrate the stream-subject-writer tests to the
  ReadOnlyMemory<byte> overload.

Wire format is unchanged everywhere, so existing cached/published data still
deserializes. No public API change beyond the two [Obsolete] markers.

Measured with a new SerializerBenchmark ([MemoryDiagnoser]): ~2x fewer
allocations on serialize/deserialize (e.g. large-payload deserialize
1,385,629 B -> 607,714 B; serialize 778,689 B -> 389,660 B) with equal-or-better
throughput. The benchmark also compared a ReadOnlySequence<byte>/Utf8JsonReader
read variant; it allocated the same as the span path but ran slower on these
single-segment payloads, so the span path was chosen.

Full suite green on net8.0 and net10.0 (1178/1178 each).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>
@cosmin-staicu
cosmin-staicu force-pushed the perf/utf8-serializer branch from 0a98e9f to 1dbe334 Compare July 17, 2026 09:38
@sonarqubecloud

Copy link
Copy Markdown

@cosmin-staicu
cosmin-staicu merged commit 55c06af into main Jul 17, 2026
10 checks passed
@cosmin-staicu
cosmin-staicu deleted the perf/utf8-serializer branch July 17, 2026 09:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants