SQL-wise, you only need to join to the wp_posts table once. Joining to the terms stuff will give you multiple rows, so it’s probably easiest to group these and then use GROUP_CONCAT() to flatten the terms into a comma-separated string (updated to use LEFT joins):
global $wpdb;
$sql = $wpdb->prepare(
'SELECT p.ID, p.post_title AS Product, pm1.meta_value AS _retail_price, pm2.meta_value AS _stock'
. ', GROUP_CONCAT(t.name ORDER BY t.name) AS Categories'
. ' FROM ' . $wpdb->posts . ' p'
. ' LEFT JOIN ' . $wpdb->postmeta . ' pm1 ON pm1.post_id = p.ID AND pm1.meta_key = %s'
. ' LEFT JOIN ' . $wpdb->postmeta . ' pm2 ON pm2.post_id = p.ID AND pm2.meta_key = %s'
. ' LEFT JOIN ' . $wpdb->term_relationships . ' AS tr ON tr.object_id = p.ID'
. ' LEFT JOIN ' . $wpdb->term_taxonomy . ' AS tt ON tr.term_taxonomy_id = tt.term_taxonomy_id AND tt.taxonomy = %s'
. ' LEFT JOIN ' . $wpdb->terms . ' AS t ON tt.term_id = t.term_id'
. ' WHERE p.post_type in (%s, %s) AND p.post_status = %s'
. ' GROUP BY p.ID'
. ' ORDER BY p.ID ASC'
, '_price', '_stock', 'product_cat', 'product', 'product_variation', 'publish'
);
$ret = $wpdb->get_results( $sql );