Skip to content

[SYSTEMDS-3946] Enable sending of large (>2GiB) FederatedRequests and… - #2496

Open
Biranavan-Parameswaran wants to merge 8 commits into
apache:mainfrom
Biranavan-Parameswaran:SYSTEMDS-3946-large-federated-requests
Open

[SYSTEMDS-3946] Enable sending of large (>2GiB) FederatedRequests and…#2496
Biranavan-Parameswaran wants to merge 8 commits into
apache:mainfrom
Biranavan-Parameswaran:SYSTEMDS-3946-large-federated-requests

Conversation

@Biranavan-Parameswaran

@Biranavan-ParameswaranBiranavan-Parameswaran commented Jun 21, 2026

Copy link
Copy Markdown

Federated transfers previously failed for payloads above 2 GiB. Netty's frame length field is a signed 32-bit integer, so a single request or response is capped at Integer.MAX_VALUE bytes.

This patch adds a streaming chunked codec. The sender splits a serialized payload into bounded frames and the receiver reassembles them, so the size of one logical message on the wire is no longer limited by a single frame.

Wire format

Every message is preceded by a one byte format marker, so the receiver knows which decoder to use: MARKER_OBJECT_ENCODER or MARKER_CHUNKED. A chunked message then follows as a sequence of frames:

1 byte frame type (TYPE_DATA, TYPE_END, TYPE_ERROR)
4 bytes payload length, big endian
n bytes payload

FederatedChunkEncoder serializes on a pool thread and feeds frames through Netty's ChunkedWriteHandler. FederatedChunkDecoder reassembles frames into an ObjectInputStream that the deserializer consumes as they arrive. Neither side ever materializes the whole payload as a single array, while transferring.

Backpressure runs in both directions: a sender that outruns the socket and a receiver that outruns the deserializer each pause until the other side catches up, so a 4 GB message still holds only about 64 MB in memory at a time when transferring.

Routing

FederatedFormatEncoder.useObjectEncoder() uses the legacy ObjectEncoder only when the lineage cache is active and the message is a lineage cacheable FederatedResponse below STREAM_THRESHOLD, which is 2000 MiB or 1.953 GiB. The
lineage cache is off by default, so in a default configuration every message takes the chunked path.

Why the legacy encoder is kept at all: the lineage serialization cache (LineageCache.putSerializedObject) stores an entire serialized response as one INT_MAX bounded byte[] and only the ObjectEncoder path produces that array. Streaming never materializes it. So the legacy path survives for exactly the case that requires it and for nothing else.

A size guard is still needed because that byte[] cannot exceed INT_MAX, so a response too large for it has to stream whether or not it is cacheable.

The guard is deliberately 2000 MiB rather than INT_MAX - 1. It sees an estimate of the raw payload, but the wire adds a measured 0.4883% of framing, so a payload gated at INT_MAX - 1 overflows by about 10 MB. The wire was measured to reach Integer.MAX_VALUE at a raw payload of about 1.990 GiB, and 2000 MiB sits around 38 MiB under that.

Experiments

One client and one worker on the same host, timed as the client's Total elapsed time over an elementwise operation on a federated matrix, so the matrix crosses the codec. AMD Ryzen 5 4600H, 12 cores, 11 GB RAM, OpenJDK
17.0.10 on WSL2, both JVMs at -Xmx8g -Xms1g -Xmn256m.

fig1_performance
payloadobject encoderchunkedratio
80 KB1.398 ± 0.059 s1.439 ± 0.065 s0.97x
8 MB2.218 ± 0.080 s2.238 ± 0.074 s0.99x
512 MB86.142 ± 3.802 s20.527 ± 2.138 s4.20x faster
1 GiB324.350 ± 6.497 s64.478 ± 5.273 s5.03x faster

n = 15 for 80 KB and 8 MB, n = 3 for 512 MB and 1 GiB. Small payloads require extra runs since their effect size matches the background noise between runs. At 512 MB and 1 GiB the gap is 4x to 5x, far outside the spread, and one 1 GiB pair already costs about 6.5 minutes.

Small payloads: no measurable regression. Chunked is 2.9 % slower at 80 KB and 0.9 % at 8 MB, both within the run to run noise. The overhead is fixed per message, so it does not grow with the payload.

Large payloads. The object encoder holds the whole message as one contiguous buffer at both ends and cannot start sending before serialization finishes. The chunked codec overlaps the two and never holds more than the frame queue, so cost stays close to linear and memory stays bounded.

Different baseline. These figures supersede an earlier table in this thread, which compared against an outdated baseline.

Chunk size

4 MB (1 << 22), from a sweep over 256K, 1M, 4M and 8M across four payloads, 48 runs. Median fed_+ in seconds, the federated instruction time, not the full round trip of the table above:

fig2_chunk_size

Median fed_+ against chunk size, as a percentage faster than the 256 KB chunk, one line per payload. 80 KB is not drawn, it is one frame at every chunk size.

payloadframes at 256K / 1M / 4M / 8M256K1M4M8M
0.08 MB1 / 1 / 1 / 10.7550.7870.9000.835
8 MB31 / 8 / 2 / 11.2081.1651.0991.066
512 MB1954 / 489 / 123 / 6210.70710.20310.01310.443
1074 MB4096 / 1024 / 256 / 12817.43916.02015.57815.917

4 MB wins at 512 MB and at 1074 MB and holds as the payload doubles, while 8M gives part of that back (4.3 % slower at 512 MB, 2.2 % at 1074 MB) and 256K pays too much framing overhead. 8M leads the 8 MB row only because the payload fits in one frame there, and 0.08 MB is one frame at every size, so neither row says anything about chunking. 4 MB also caps buffering in flight at 64 MB.

Above the 2 GiB cap

The range the codec exists for. One run per size, on a different macOS host with about 20 GB of usable RAM and a lighter workload, so these absolute numbers do not compare with the round trip table further up. The trend within the
table is the point:

fig3_memory

Peak resident memory of the worker against payload size, above the 2 GiB single frame cap with a 2x payload reference line. Sampled every 0.4 s, 1 run per payload.

payloadround tripthroughputpeak worker RSS
2.2 GB11.56 s190 MB/s5961 MB
2.6 GB14.26 s182 MB/s7689 MB
3.0 GB16.14 s186 MB/s6744 MB
4.0 GB25.74 s155 MB/s9795 MB

All correct, 0 exceptions, 0 OOM. Throughput stays flat and peak worker RSS stays at 2.2x to 3.0x the payload, which is the workload's own two copies, the received matrix plus the result, with no serialization buffer on top. The object encoder
cannot reach this range at all, it fails at about 1.99 GiB.

Known limitations

  • Every measurement above is a sequential single client round trip. Because routing now sends essentially all traffic through the chunked path with two extra pool tasks per message, behavior under many concurrent small requests is not yet measured.
  • The lineage serialization cache still requires the legacy encoder, so the ObjectEncoder path cannot be removed until that cache can accept a streamed response.

@Biranavan-Parameswaran
Biranavan-Parameswaran marked this pull request as ready for review June 21, 2026 16:15
@ywcb00ywcb00 self-assigned this Jun 22, 2026

@ywcb00ywcb00 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you very much for the PR @Biranavan-Parameswaran :)
I left some minor comments in the code. Could you please have a look at it and resolve it if you find the time. Thanks.


static final byte MARKER_LEGACY = 0;
static final byte MARKER_CHUNKED = 1;
static final long STREAM_THRESHOLD = 1536L << 20; // ~1.5 GB: route below this through the legacy object codec

@ywcb00ywcb00Jun 22, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should use the regular encoder as long as we can, i.e., up to the largest possible message size. Can we increase this default threshold from 1.5GB to (INT_MAX - 1) bytes?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good suggestion, but INT_MAX-1 is actually unsafe here. Routing uses a size estimate, not the exact wire size, and the ObjectEncoder overflows its Integer.MAX_VALUEByteBuf once serialization framing is added. I measured that cliff at about 1.990 GiB, so an estimate just under INT_MAX can still overflow on the wire. I set the threshold to 2000L << 20 (about 1.953 GiB), roughly 40 MB under the cliff, so the regular encoder is used as high as we safely can.

Note the routing also changed since your review. Responses now route by lineage cacheability instead of size, so STREAM_THRESHOLD is now the size guard on the object encoder path rather than the main router.

@github-project-automationgithub-project-automationBot moved this from In Progress to In Review in SystemDS PR QueueJun 22, 2026
@Biranavan-Parameswaran
Biranavan-Parameswaranforce-pushed the SYSTEMDS-3946-large-federated-requests branch from f5073a4 to ec0538bCompareJuly 15, 2026 18:58
@Biranavan-Parameswaran
Biranavan-Parameswaranforce-pushed the SYSTEMDS-3946-large-federated-requests branch from b5e4165 to ec0538bCompareJuly 22, 2026 22:09
@Biranavan-Parameswaran

Copy link
Copy Markdown
Author

Thanks for the review @ywcb00. All three comments are addressed, and I also ran experiments to back the design choices. Summary of what changed since the reviewed commit.

Comments

  • Threshold (1):INT_MAX-1 can overflow on the wire because routing uses a size estimate. I measured the overflow cliff at about 1.990 GiB and set the threshold about 40 MB under it (2000L<<20). Full reasoning in the inline reply.
  • Redundant catch (2): removed.
  • Rename (3):FederatedFormatDetector is now FederatedFormatDecoder.

Follow up work

  • Chunk size 4 MB (1<<22). Swept 256K, 1M, 4M and 8M across four payloads, 48 runs. Median round trip in seconds:

    payload256K1M4M8M
    0.08 MB0.7550.7870.9000.835
    8 MB1.2081.1651.0991.066
    512 MB10.70710.20310.01310.443
    1074 MB17.43916.02015.57815.917

    4 MB is the robust optimum. It wins on large and xl and stays stable as the payload doubles. 8M starts to regress on big payloads and 256K pays too much framing overhead. Small payloads are one frame so they are chunk size indifferent. It also caps in flight buffering at 16 x 4 MB, so 64 MB.

  • Streaming vs object encoder. Mean elapsed in seconds, object encoder vs chunked:

    payloadobject encoderchunked
    tiny1.361.46
    mid2.162.21
    512 MB74.118.5
    1 GiB305.529.9

    Chunked matches the object encoder on small payloads and is 4x to 10x faster on large ones.

  • Scaling past 2 GB. Swept 2.2 to 4.0 GB, all correct with 0 exceptions and 0 OOM:

    payloadround trip sMB/speak worker MB
    2.2 GB11.561905961
    2.6 GB14.261827689
    3.0 GB16.141866744
    4.0 GB25.741559795

    Throughput stays flat around 155 to 190 MB/s and worker peak memory tracks payload at about 2.2 to 3.0x with no second full payload buffer. The old object encoder crashes near 1.99 GiB, so this is the range the codec exists for.

  • Routing now based on lineage cacheability instead of size. Lineage cacheable responses use the object encoder, everything else streams. New FederatedFormatRoutingTest covers the four cases.

  • Close race fix. The worker CloseListener treated a client close during a chunked response drain as a write failure and tried a second write on the dead channel. It now treats an inactive channel mid stream as a normal end of stream.

  • Slimmed FederatedChunkEncoder to a factory and tightened the routing predicate.

@Biranavan-Parameswaran
Biranavan-Parameswaranforce-pushed the SYSTEMDS-3946-large-federated-requests branch from 661bf3f to 0e2fc2aCompareJuly 23, 2026 07:56
@codecov

codecovBot commented Jul 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.82192% with 42 lines in your changes missing coverage. Please review.
✅ Project coverage is 71.59%. Comparing base (fa9824c) to head (60c5c24).
⚠️ Report is 3 commits behind head on main.

Files with missing linesPatch %Lines
...ontrolprogram/federated/FederatedChunkEncoder.java79.74%14 Missing and 2 partials ⚠️
...ontrolprogram/federated/FederatedChunkDecoder.java81.57%10 Missing and 4 partials ⚠️
...ntrolprogram/federated/FederatedWorkerHandler.java54.54%4 Missing and 1 partial ⚠️
...ntrolprogram/federated/FederatedFormatEncoder.java85.71%2 Missing and 1 partial ⚠️
...ntrolprogram/federated/FederatedFormatDecoder.java81.81%1 Missing and 1 partial ⚠️
...untime/controlprogram/federated/FederatedData.java92.85%0 Missing and 1 partial ⚠️
...me/controlprogram/federated/FederatedResponse.java0.00%0 Missing and 1 partial ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #2496 +/- ##
============================================
- Coverage 71.60% 71.59% -0.01% - Complexity 50259 50390 +131 
============================================
Files 1623 1631 +8 Lines 194314 194856 +542 Branches 37965 38025 +60 ============================================
+ Hits 139130 139506 +376 - Misses 44277 44413 +136 - Partials 10907 10937 +30 

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@ywcb00ywcb00 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you very much for this contribution. :)
The code looks very good. I only added some minor comments regarding variable naming.
A general aspect to consider: Do you think it is possible to make the FederatedChunkCodecTest even faster in terms of runtime needed for the test while testing the same functionality?
All the best,
David


static final int HEADER_LEN = 5;
static final int DEFAULT_CHUNK_SIZE = 1 << 22;
static final int QUEUE_DEPTH = 16;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How did you decide on this number here? Was it chosen arbitrarily or are there certain factors/reasons behind this decision?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's randomly chosen because the queue never fills up locally to test it.

Happy to adjust or make it configurable.

… Responses
Federated transfers previously failed for payloads above 2GiB because the
single Netty frame size is bounded by a 32-bit length field, capping any
request or response at Integer.MAX_VALUE bytes.
This patch adds a streaming chunked codec that splits a large payload into
bounded frames on the sender and reassembles them on the receiver, so the
on-wire size is no longer limited by a single frame. A format detector and
format encoder select the chunked path only when the payload exceeds the
frame limit, leaving the existing small-message path unchanged to avoid
added overhead for the common case.
Adds FederatedMaxPayloadTest to exercise the boundary around the former
2GiB cap.
@Biranavan-Parameswaran
Biranavan-Parameswaranforce-pushed the SYSTEMDS-3946-large-federated-requests branch from a9ab2a8 to 4b681efCompareJuly 28, 2026 20:52
@Biranavan-Parameswaran

Copy link
Copy Markdown
Author

Thank you very much for this contribution. :) The code looks very good. I only added some minor comments regarding variable naming. A general aspect to consider: Do you think it is possible to make the FederatedChunkCodecTest even faster in terms of runtime needed for the test while testing the same functionality? All the best, David

Thanks @ywcb00 , all comments resolved! :)

Regarding FederatedChunkCodecTest: I tried a few variants. The actual work is only ~50ms. I have a slightly faster version, but it costs a lot of readability in the polling helpers. Happy to commit it if you'd prefer.

@Biranavan-Parameswaran
Biranavan-Parameswaranforce-pushed the SYSTEMDS-3946-large-federated-requests branch from 32bb284 to d96e845CompareAugust 1, 2026 22:50
Document the codec on methods and fail fast on an unknown frame type in the
chunk decoder.
@Biranavan-Parameswaran
Biranavan-Parameswaranforce-pushed the SYSTEMDS-3946-large-federated-requests branch from d96e845 to bc74634CompareAugust 1, 2026 22:52
Both negative tests go through a helper, since writeInbound rethrows if
the deserializer thread wins the race.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Review

Development

Successfully merging this pull request may close these issues.

2 participants

@Biranavan-Parameswaran@ywcb00