Skip to content

Commit 895097b

Browse files
authored
feat: pipeline file parts within each download connection (#107)
* feat: pipeline file parts within each download connection Workers requested their block's 1MB parts sequentially, paying a full round-trip of dead time per part and capping each connection around 3-4 MB/s regardless of its server-side allowance - the multi-connection download barely improved on a single connection (~10 MB/s with dips). Request all parts of a block concurrently on the block's connection, keeping each connection's pipe full (the same pipelining official clients use), and log a completion summary with the effective average speed to make future tuning measurable. Part writes stay positional and block completion still gates the contiguous confirmed prefix used for progress and resume persistence. * feat: make multi-connection tuning configurable with documented defaults Expose the remaining hardcoded transfer knobs in General Config: chunk size (snapped to Telegram's allowed 128/256/512/1024 KB values, 512 being WTelegramClient's own default), block size per connection (which determines the requests in flight per connection) and the minimum file size for multi-connection mode. Values are captured once per download so a config change cannot desynchronize offsets mid-transfer. The Config page now states the library default, app default and recommended value for each transfer setting, so the stock WTelegramClient behavior (2 parallel chunks, 512KB parts, single connection) can be restored by configuration alone.
1 parent 1179efb commit 895097b

3 files changed

Lines changed: 179 additions & 44 deletions

File tree

TelegramDownloader/Data/TelegramService.cs

Lines changed: 87 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -271,19 +271,42 @@ private class DownloadPool
271271
}
272272

273273
private static readonly DownloadPool downloadPool = new DownloadPool();
274-
private const int MULTICONN_PART_SIZE = 1024 * 1024; // upload.getFile max limit per request
275-
private const int MULTICONN_BLOCK_SIZE = 4 * 1024 * 1024; // work unit assigned to a connection
276-
private const long MULTICONN_MIN_FILE_SIZE = 32L * 1024 * 1024;
277274

278275
public static int GetConfiguredDownloadConnections()
279276
{
280277
return Math.Clamp(GeneralConfigStatic.config?.DownloadConnections ?? 4, 2, 8);
281278
}
282279

280+
/// <summary>
281+
/// Chunk size for upload.getFile: Telegram only accepts limits that are
282+
/// divisible by 4KB and divide 1MB evenly, so the configured value is
283+
/// snapped to 128/256/512/1024 KB. 512 is WTelegramClient's own default;
284+
/// 1024 (app default) halves the number of round-trips.
285+
/// </summary>
286+
private static int GetConfiguredPartSize()
287+
{
288+
int kb = GeneralConfigStatic.config?.MultiConnectionPartSizeKB ?? 1024;
289+
if (kb >= 1024) return 1024 * 1024;
290+
if (kb >= 512) return 512 * 1024;
291+
if (kb >= 256) return 256 * 1024;
292+
return 128 * 1024;
293+
}
294+
295+
private static int GetConfiguredBlockSize(int partSize)
296+
{
297+
int mb = Math.Clamp(GeneralConfigStatic.config?.MultiConnectionBlockSizeMB ?? 4, 1, 16);
298+
return Math.Max(mb * 1024 * 1024, partSize);
299+
}
300+
301+
private static long GetConfiguredMinFileSize()
302+
{
303+
return Math.Max(1, GeneralConfigStatic.config?.MultiConnectionMinFileSizeMB ?? 32) * 1024L * 1024L;
304+
}
305+
283306
private static bool ShouldUseMultiConnection(TL.Document document)
284307
{
285308
GeneralConfig cfg = GeneralConfigStatic.config;
286-
return cfg != null && cfg.EnableMultiConnectionDownloads && document.size >= MULTICONN_MIN_FILE_SIZE;
309+
return cfg != null && cfg.EnableMultiConnectionDownloads && document.size >= GetConfiguredMinFileSize();
287310
}
288311

289312
private async Task<List<WTelegram.Client>> GetDownloadPoolAsync(int count)
@@ -448,33 +471,68 @@ private async Task<bool> TryMultiConnectionDownloadAsync(TL.Document document, F
448471
thumb_size = ""
449472
};
450473

451-
_logger.LogInformation("Multi-connection download - FileName: {Name}, Size: {SizeMB:F2}MB, Connections: {Connections}",
452-
model.name, size / (1024.0 * 1024.0), transfers.Count);
474+
// Capture the tuning values once so a config change mid-download
475+
// cannot desynchronize offsets.
476+
int partSize = GetConfiguredPartSize();
477+
int blockSize = GetConfiguredBlockSize(partSize);
453478

454-
long blockCount = (size + MULTICONN_BLOCK_SIZE - 1) / MULTICONN_BLOCK_SIZE;
479+
_logger.LogInformation("Multi-connection download - FileName: {Name}, Size: {SizeMB:F2}MB, Connections: {Connections}, Part: {PartKB}KB, Block: {BlockMB}MB",
480+
model.name, size / (1024.0 * 1024.0), transfers.Count, partSize / 1024, blockSize / (1024.0 * 1024.0));
481+
482+
long blockCount = (size + blockSize - 1) / blockSize;
455483
bool[] blockDone = new bool[blockCount];
456484
long confirmedBlocks = 0;
457485
long nextBlock = -1;
458486
object progressLock = new object();
459487
using CancellationTokenSource cts = new CancellationTokenSource();
460488
dest.SetLength(size);
461489
var handle = dest.SafeFileHandle;
490+
DateTime started = DateTime.Now;
462491

463-
void ReportPart(long block, int received, bool blockCompleted)
492+
void ReportBytes(int received)
493+
{
494+
long confirmed;
495+
lock (progressLock)
496+
confirmed = Math.Min(size, confirmedBlocks * (long)blockSize);
497+
// Throws when the task gets canceled or paused, stopping the workers.
498+
model.ReportParallelProgress(confirmed, received, size);
499+
}
500+
501+
void ReportBlockDone(long block)
464502
{
465503
long confirmed;
466504
lock (progressLock)
467505
{
468-
if (blockCompleted)
506+
blockDone[block] = true;
507+
while (confirmedBlocks < blockCount && blockDone[confirmedBlocks])
508+
confirmedBlocks++;
509+
confirmed = Math.Min(size, confirmedBlocks * (long)blockSize);
510+
}
511+
model.ReportParallelProgress(confirmed, 0, size);
512+
}
513+
514+
async Task DownloadPart(WTelegram.Client pc, long offset)
515+
{
516+
int expected = (int)Math.Min(partSize, size - offset);
517+
for (int attempt = 1; ; attempt++)
518+
{
519+
cts.Token.ThrowIfCancellationRequested();
520+
try
469521
{
470-
blockDone[block] = true;
471-
while (confirmedBlocks < blockCount && blockDone[confirmedBlocks])
472-
confirmedBlocks++;
522+
Upload_FileBase resp = await pc.Upload_GetFile(location, offset, limit: partSize);
523+
if (resp is not Upload_File part)
524+
throw new InvalidOperationException($"Unexpected {resp?.GetType().Name} from Upload_GetFile (CDN-served files are not supported)");
525+
if (part.bytes.Length < expected)
526+
throw new InvalidOperationException($"Short chunk at offset {offset}: {part.bytes.Length} < {expected}");
527+
RandomAccess.Write(handle, part.bytes.AsSpan(0, expected), offset);
528+
ReportBytes(expected);
529+
return;
530+
}
531+
catch (Exception) when (attempt < 3 && !cts.IsCancellationRequested)
532+
{
533+
await Task.Delay(1000 * attempt);
473534
}
474-
confirmed = Math.Min(size, confirmedBlocks * (long)MULTICONN_BLOCK_SIZE);
475535
}
476-
// Throws when the task gets canceled or paused, stopping the workers.
477-
model.ReportParallelProgress(confirmed, received, size);
478536
}
479537

480538
async Task Worker(WTelegram.Client pc)
@@ -484,32 +542,17 @@ async Task Worker(WTelegram.Client pc)
484542
long block = Interlocked.Increment(ref nextBlock);
485543
if (block >= blockCount)
486544
return;
487-
long offset = block * (long)MULTICONN_BLOCK_SIZE;
488-
long end = Math.Min(size, offset + MULTICONN_BLOCK_SIZE);
489-
while (offset < end)
490-
{
491-
cts.Token.ThrowIfCancellationRequested();
492-
Upload_FileBase resp = null;
493-
for (int attempt = 1; ; attempt++)
494-
{
495-
try
496-
{
497-
resp = await pc.Upload_GetFile(location, offset, limit: MULTICONN_PART_SIZE);
498-
break;
499-
}
500-
catch (Exception) when (attempt < 3 && !cts.IsCancellationRequested)
501-
{
502-
await Task.Delay(1000 * attempt);
503-
}
504-
}
505-
if (resp is not Upload_File part)
506-
throw new InvalidOperationException($"Unexpected {resp?.GetType().Name} from Upload_GetFile (CDN-served files are not supported)");
507-
if (part.bytes.Length == 0)
508-
throw new InvalidOperationException($"Empty chunk at offset {offset}");
509-
RandomAccess.Write(handle, part.bytes, offset);
510-
offset += part.bytes.Length;
511-
ReportPart(block, part.bytes.Length, offset >= end);
512-
}
545+
long blockStart = block * (long)blockSize;
546+
long blockEnd = Math.Min(size, blockStart + blockSize);
547+
// Request every part of the block concurrently on this
548+
// connection: sequential parts pay a full round-trip of dead
549+
// time each, capping a connection well below its server-side
550+
// allowance. Pipelining keeps the connection's pipe full.
551+
List<Task> parts = new List<Task>();
552+
for (long offset = blockStart; offset < blockEnd; offset += partSize)
553+
parts.Add(DownloadPart(pc, offset));
554+
await Task.WhenAll(parts);
555+
ReportBlockDone(block);
513556
}
514557
}
515558

@@ -523,6 +566,9 @@ async Task GuardedWorker(WTelegram.Client pc)
523566
{
524567
await Task.WhenAll(transfers.Select(pc => Task.Run(() => GuardedWorker(pc))));
525568
await dest.FlushAsync();
569+
double seconds = Math.Max(0.001, (DateTime.Now - started).TotalSeconds);
570+
_logger.LogInformation("Multi-connection download completed - FileName: {Name}, {SizeMB:F1}MB in {Seconds:F1}s = {Speed:F1} MB/s over {Connections} connections",
571+
model.name, size / (1024.0 * 1024.0), seconds, size / (1024.0 * 1024.0) / seconds, transfers.Count);
526572
return true;
527573
}
528574
catch (Exception ex)

TelegramDownloader/Models/GeneralConfig.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,9 +204,36 @@ public StreamingMode GetEffectiveStreamingMode()
204204
/// <summary>
205205
/// Number of parallel connections used per file download (2-8).
206206
/// Only used when EnableMultiConnectionDownloads is true.
207+
/// App default: 4. Recommended: 4-8.
207208
/// </summary>
208209
public int DownloadConnections { get; set; } = 4;
209210

211+
/// <summary>
212+
/// Size in KB of each file chunk requested (upload.getFile limit).
213+
/// Telegram only allows 128, 256, 512 or 1024 (values are snapped to
214+
/// the nearest allowed one). WTelegramClient's own default is 512;
215+
/// app default is 1024 (fewer round-trips).
216+
/// Only used when EnableMultiConnectionDownloads is true.
217+
/// </summary>
218+
public int MultiConnectionPartSizeKB { get; set; } = 1024;
219+
220+
/// <summary>
221+
/// Size in MB (1-16) of the work unit assigned to each connection.
222+
/// All parts of a block are requested in parallel on the same
223+
/// connection, so BlockSize / PartSize = requests in flight per
224+
/// connection. App default: 4 (= 4 x 1MB in flight).
225+
/// Only used when EnableMultiConnectionDownloads is true.
226+
/// </summary>
227+
public int MultiConnectionBlockSizeMB { get; set; } = 4;
228+
229+
/// <summary>
230+
/// Files smaller than this size in MB use the normal single-connection
231+
/// download (the setup cost is not worth it for small files).
232+
/// App default: 32.
233+
/// Only used when EnableMultiConnectionDownloads is true.
234+
/// </summary>
235+
public int MultiConnectionMinFileSizeMB { get; set; } = 32;
236+
210237
}
211238

212239
public class TLConfig

TelegramDownloader/Pages/Config.razor

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,8 @@
237237
Parallel Chunk Transfers
238238
</div>
239239
<div class="config-item-description">
240-
Number of 512KB chunks requested in parallel per transfer (1-16). Higher values improve download/upload speed by removing the latency bottleneck; Premium accounts benefit the most. Applied on the next transfer.
240+
Number of chunks requested in parallel per transfer (1-16), used by standard (single-connection) downloads and uploads. Higher values remove the latency bottleneck; Premium accounts benefit the most. Applied on the next transfer.
241+
<br /><small class="text-muted">WTelegramClient default: <b>2</b> &middot; App default: <b>4</b> &middot; Recommended: <b>4-8</b></small>
241242
</div>
242243
</div>
243244
<div class="config-item-control">
@@ -252,10 +253,11 @@
252253
Multi-Connection Downloads
253254
</div>
254255
<div class="config-item-description">
255-
Download large files (&gt;32MB) using several parallel connections, like Telegram Desktop does.
256+
Download large files using several parallel connections, like Telegram Desktop does.
256257
Telegram limits speed per connection (~5-6 MB/s), so this is the way to go faster.
257258
The extra connections share your existing session authorization, so nothing new appears in your Telegram device list.
258259
<span class="badge bg-warning text-dark ms-2">Experimental</span>
260+
<br /><small class="text-muted">Default: <b>disabled</b> (library behavior: one connection per download) &middot; Recommended: <b>enabled</b> for large files on fast lines</small>
259261
</div>
260262
</div>
261263
<div class="config-item-control">
@@ -272,13 +274,73 @@
272274
Download Connections
273275
</div>
274276
<div class="config-item-description">
275-
Number of parallel connections used per file download (2-8)
277+
Number of parallel connections used per file download (2-8).
278+
<br /><small class="text-muted">App default: <b>4</b> &middot; Recommended: <b>4-8</b> (Telegram Desktop uses up to 8)</small>
276279
</div>
277280
</div>
278281
<div class="config-item-control">
279282
<NumberInput TValue="int" @bind-Value="Model!.DownloadConnections" Min="2" Max="8" EnableMinMax="true" class="form-control" style="width: 80px;" />
280283
</div>
281284
</div>
285+
286+
<div class="config-item">
287+
<div class="config-item-info">
288+
<div class="config-item-label">
289+
<i class="bi bi-hdd-stack"></i>
290+
Chunk Size
291+
</div>
292+
<div class="config-item-description">
293+
Size of each file chunk requested from Telegram. Bigger chunks mean fewer round-trips.
294+
<br /><small class="text-muted">WTelegramClient default: <b>512 KB</b> &middot; App default and recommended: <b>1024 KB</b></small>
295+
</div>
296+
</div>
297+
<div class="config-item-control">
298+
<InputSelect @bind-Value="Model!.MultiConnectionPartSizeKB" class="form-select" style="width: 120px;">
299+
<option value="128">128 KB</option>
300+
<option value="256">256 KB</option>
301+
<option value="512">512 KB</option>
302+
<option value="1024">1024 KB</option>
303+
</InputSelect>
304+
</div>
305+
</div>
306+
307+
<div class="config-item">
308+
<div class="config-item-info">
309+
<div class="config-item-label">
310+
<i class="bi bi-collection"></i>
311+
Block Size
312+
</div>
313+
<div class="config-item-description">
314+
Work unit assigned to each connection (1-16 MB). All chunks of a block are requested in parallel on the same connection, so Block &divide; Chunk = requests in flight per connection.
315+
<br /><small class="text-muted">App default: <b>4 MB</b> (= 4 chunks of 1024 KB in flight) &middot; Recommended: <b>4-8 MB</b></small>
316+
</div>
317+
</div>
318+
<div class="config-item-control">
319+
<div class="config-input-group">
320+
<NumberInput TValue="int" @bind-Value="Model!.MultiConnectionBlockSizeMB" Min="1" Max="16" EnableMinMax="true" class="form-control" style="width: 80px;" />
321+
<span class="text-muted">MB</span>
322+
</div>
323+
</div>
324+
</div>
325+
326+
<div class="config-item">
327+
<div class="config-item-info">
328+
<div class="config-item-label">
329+
<i class="bi bi-rulers"></i>
330+
Min. File Size
331+
</div>
332+
<div class="config-item-description">
333+
Files smaller than this use the normal single-connection download (the setup is not worth it for small files).
334+
<br /><small class="text-muted">App default and recommended: <b>32 MB</b></small>
335+
</div>
336+
</div>
337+
<div class="config-item-control">
338+
<div class="config-input-group">
339+
<NumberInput TValue="int" @bind-Value="Model!.MultiConnectionMinFileSizeMB" Min="1" Max="1024" EnableMinMax="true" class="form-control" style="width: 90px;" />
340+
<span class="text-muted">MB</span>
341+
</div>
342+
</div>
343+
</div>
282344
}
283345

284346
<div class="config-item">

0 commit comments

Comments
 (0)