Why is pre_get_posts hook invoked multiple times?

The pre_get_posts action runs any time posts are queried. This obviously includes the main query for displaying your latest posts or the current page, but it also includes any other time posts are queried. So that would include any use of WP_Query() or get_posts() in a plugin or your templates, any Recent Posts or similar widgets, and menus (menu items are actually a post type, so any menu on your site will involve a posts query).

If you only want to affect the main query (that being the one you loop over with have_posts(), you need to check $query->is_main_query():

function wpse_318765_pre_get_posts_callback( $query ) {
    if ( $query->is_main_query() ) {

    }
}