2

I am trying to find way to further improve the performance of my console app (already fully working).

I have a CSV file which contains a list of addresses (about 100k). I need to query a Web API whose POST response would be the geographical coordinates of such addresses. Then I am going to write a GeoJSON file to the file system with the address data enriched with geographical coordinates (latitude and longitude).

My current solution splits the data into batches of 1000 records and sends Async POST requests to the Web API using HttpClient (.NET core 3.1 with console app and class library using .NET Standard 2.0). GeoJSON is my DTO class.

public class GeoJSON
    {
        public string Locality { get; set; }
        public string Street { get; set; }
        public string StreetNumber { get; set; }
        public string ZIP { get; set; }
        public string Latitude { get; set; }
        public string Longitude { get; set; }
    }


public static async Task<List<GeoJSON>> GetAddressesInParallel(List<GeoJSON> geos)
        {
            //calculating number of batches based on my batchsize (1000)
            int numberOfBatches = (int)Math.Ceiling((double)geos.Count() / batchSize);

            for (int i = 0; i < numberOfBatches; i++)
            {
                var currentIds = geos.Skip(i * batchSize).Take(batchSize);
                var tasks = currentIds.Select(id => SendPOSTAsync(id));
                geoJSONs.AddRange(await Task.WhenAll(tasks));
            }

            return geoJSONs;
        }

My Async POST method looks like this:

 public static async Task<GeoJSON> SendPOSTAsync(GeoJSON geo)
        {
            string payload = JsonConvert.SerializeObject(geo);
            HttpContent c = new StringContent(payload, Encoding.UTF8, "application/json");
            using HttpResponseMessage response = await client.PostAsync(URL, c).ConfigureAwait(false);

            if (response.IsSuccessStatusCode)
            {
                var address = JsonConvert.DeserializeObject<GeoJSON>(await response.Content.ReadAsStringAsync());
                geo.Latitude = address.Latitude;
                geo.Longitude = address.Longitude;
            }
            return geo;
        }

The Web API runs on my local machine as Self Hosted x86 application. The whole application ends in less than 30s. The most time consuming part is the Async POST part (about 25s). The Web API takes only one address for each post, otherwise I'd have sent multiple addresses in one request.

Any ideas on how to improve performance of the request against the Web API?

fpsanti
  • 23
  • 1
  • 5
  • 2
    https://ericlippert.com/2012/12/17/performance-rant/ very good read about performance from Eric Lippert. – Trevor Jan 02 '20 at 14:45
  • _"The most time consuming part is the Async POST part"_ - yeah so the external server is probably throttling you... – CodeCaster Jan 02 '20 at 14:47
  • [Throttling asynchronous tasks](https://stackoverflow.com/a/22493662/7444103) -- [Queue of async tasks with throttling which supports muti-threading](https://stackoverflow.com/q/34315589/7444103) – Jimi Jan 02 '20 at 14:58
  • 1000 concurrent requests sounds excessive. Have you tried with smaller numbers? – Theodor Zoulias Jan 02 '20 at 16:07
  • @TheodorZoulias I started with 100 and went up during my tests. 1000 seems to return the best results. With 100 concurrent requests the app took on average about 6 seconds more than with 100. – fpsanti Jan 02 '20 at 16:53
  • @Çöđěxěŕ I am just trying to achieve the max performance possible for the sake of learning and be prepared for the day, when the real need comes. :) BTW I was inspired by this http://www.michalbialecki.com/2018/04/19/how-to-send-many-requests-in-parallel-in-asp-net-core/ and tried to squeeze every single resource to make it run faster. – fpsanti Jan 02 '20 at 16:59
  • Have you considered making GeoJSON a struct instead of a Class Object. A lot of overhead in initialization and memory usage can be eliminated right off the bat which may show a significant performance improvement. It isn't directly related to POST but give it a shot. – cminus Jan 02 '20 at 19:21
  • @TheodorZoulias it seems indeed the sweet spot for concurrent requests is 300. With that value I get the execution time down to about 25s. – fpsanti Jan 06 '20 at 15:21

2 Answers2

1

A potential problem of your batching approach is that a single delayed response may delay the completion of a whole batch. It may not be an actual problem because the web service you are calling may have very consistent response times, but in any case you could try an alternative approach that allows controlling the concurrency without the use of batching. The example bellow uses the TPL Dataflow library, which is built-in the .NET Core platform and available as a package for .NET Framework:

public static async Task<List<GeoJSON>> GetAddressesInParallel(List<GeoJSON> geos)
{
    var block = new ActionBlock<GeoJSON>(async item =>
    {
        await SendPOSTAsync(item);
    }, new ExecutionDataflowBlockOptions()
    {
        MaxDegreeOfParallelism = 1000
    });

    foreach (var item in geos)
    {
        await block.SendAsync(item);
    }
    block.Complete();

    await block.Completion;
    return geos;
}

Your SendPOSTAsync method just returns the same GeoJSON that receives as argument, so the GetAddressesInParallel can also return the same List<GeoJSON> that receives as argument.

The ActionBlock is the simplest of the blocks available in the library. It just executes a sync or async action for every item, allowing the configuration of the MaxDegreeOfParallelism among other options. You could also try splitting your workflow in multiple blocks, and then link them together to form a pipeline. For example:

  1. TransformBlock<GeoJSON, (GeoJSON, string)> that serializes the GeoJSON objects to JSON.
  2. TransformBlock<(GeoJSON, string), (GeoJSON, string)> that makes the HTTP requests.
  3. ActionBlock<(GeoJSON, string)> that deserializes the HTTP responses and updates the GeoJSON objects with the received values.

Such an arrangement would allow you to fine-tune the MaxDegreeOfParallelism of each block, and hopefully achieve the optimal performance.

Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
  • when I try your suggestion the application runs for about 1 min. With my original solution it takes about 30s. I've tried different values for MaxDegreeOfParallelism but could not really improve it. – fpsanti Jan 06 '20 at 15:16
  • @fpsanti thanks for the feedback. Keep in mind that the `MaxDegreeOfParallelism` configuration of this solution is constant, i.e. you have constantly 1000 active tasks from start to finish (except from the very start and very finish). The degree of parallelism of your batching solution is variable, since it starts each batch with 1000 and then it's gradually reduced to zero. It is hard to know the average DOP, but we could assume it is about half of the max. So since the optimal for the batching solution is 300, the optimal for this solution should be around 150. – Theodor Zoulias Jan 07 '20 at 03:28
  • I tried with 150 and 100 but it's still rather slow compared to my original solution. Am I missing something? – fpsanti Jan 07 '20 at 10:19
  • This is odd. The overhead of using the library is minimal, and should not be noticeable. Could you try adding the option `BoundedCapacity = 1000`? It may help to keep small the size of the block's internal queue. – Theodor Zoulias Jan 07 '20 at 11:46
  • It's still about 3 times slower than the original code. – fpsanti Jan 07 '20 at 15:36
  • Source code can be found here https://github.com/fpsanti/StreetDirectory. – fpsanti Jan 07 '20 at 15:40
  • I did a performance test, replacing the code inside `SendPOSTAsync` with a simple delay (`await Task.Delay(50)`), and the results were identical. The TPL approach performed equally good with the batching approach. So I guess that the difference is related to the inner workings of the `HttpClient`, or possibly to some details of the self-hosted Web API. I don't know really. – Theodor Zoulias Jan 11 '20 at 07:24
0

The answer above is probably correct, but this kind of dependency is not necessary. You can just use Task.WhenAll . This code is from a different Rest library but the concept is the same:

var tasks = new List<Task<Response<Person>>>();
const int maxCalls = 100;

Parallel.For(0, maxCalls, (i) =>
{
    var client = clientFactory.CreateClient();
    tasks.Add(client.GetAsync<Person>(new Uri("JsonPerson", UriKind.Relative)));
});

var results = await Task.WhenAll(tasks);

The client is created and request made in parallel 100x. Then all the tasks are awaited in parallel. This means that all available resources are utilized.

Full code

Christian Findlay
  • 6,770
  • 5
  • 51
  • 103
  • This may put the web server under too much pressure. It could also trigger some anti-DOS-attack mechanism. It is friendlier and safer to [throttle](https://stackoverflow.com/questions/10806951/how-to-limit-the-amount-of-concurrent-async-i-o-operations) I/O operations. – Theodor Zoulias Jan 04 '20 at 06:58
  • Can you elaborate on this? – Christian Findlay Jan 04 '20 at 07:03
  • As someone else pointed out on the same answer to the post you mentioned - max degree of parallelism is probably a nice approach: https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.paralleloptions.maxdegreeofparallelism?redirectedfrom=MSDN&view=netframework-4.8#System_Threading_Tasks_ParallelOptions_MaxDegreeOfParallelism. A SemaphorSlim seems like overkill there. – Christian Findlay Jan 04 '20 at 07:11
  • Your answer starts all the tasks at once. This will result to 100,000 almost simultaneous web requests. The only delay between one request and the next would be the time to serialize the payload, which should be minuscule. – Theodor Zoulias Jan 04 '20 at 07:35
  • `ParallelOptions.MaxDegreeOfParallelism` is intended for CPU-bound operations. For I/O-bound operations you need asynchronous throttlers, like the `SemaphorSlim.WaitAsync`. – Theodor Zoulias Jan 04 '20 at 07:35
  • 1
    About the problems arising from combining Parallel+async, here is one of the many related questions on this site: [Parallel foreach with asynchronous lambda](https://stackoverflow.com/questions/15136542/parallel-foreach-with-asynchronous-lambda) – Theodor Zoulias Jan 04 '20 at 07:45