nos_
ENRU

Сеть гостей: с 5,7 секунды до 2,8 — и две идеи, которые не выдержали замеров

Posted interrupts, one-shot-таймер, модерация прерываний приёма, offload, отображённые кольца — с замерами на железе; halt polling и задача для NIC не помогли.

13 мин чтения
ГипервизорСетьПроизводительностьx86-64Rust
Read in English

Тест такой: два Linux-гостя по два CPU на EX44 — сервере Hetzner с i5-13500, где nos работает на голом железе, а его гипервизор — под Intel VT-x. Один гость отправляет другому 500 МБ по TCP через коммутатор модуля hv, по килобайту за запись (dd | nc). Когда работа началась — вечером того дня, когда влился «интеловский» раунд (3912397), — это занимало в среднем 5,72 с, а к следующему вечеру — 2,72–2,83 с (2a20db5). По дороге появился второй тест: BusyBox httpd в одном госте пять раз подряд отдаёт другому файл в 100 МБ. Без offload это занимало 1,63–1,68 с, в конце — 0,29 с, то есть 1,8 ГБ/с.

Каждый шаг — отдельный коммит, и в его сообщении записан замер: сколько прогонов, какой разброс, с чем сравнивали. Все цифры сняты на кремнии EX44, если не сказано иное; гейты гонялись под вложенным KVM на той же машине. Два шага — идеи, которые быстрее не стали: они остались в документации, а не в коде.

День в одной таблице

Коммит Что убрал Замер
89a4c5a posted interrupts выход, которым CPU гостя получал IPI или MSI TCP 4,85 с в среднем вместо 5,72
e98a4de halt polling, разобранный по частям ничего: его не оставили 6,27 и 6,48 с против 3,94 и 5,08
dc416b9, 415d29d one-shot-таймер до 10 мс опоздания таймера гостя 100 × usleep 1000 за 0,16 с вместо 0,99
4696f48 TCP опрашивает всё реже пробуждения, которых стоит настоящая миллисекунда простаивающий nos просыпается 741 раз в секунду вместо 2220
4445a2d прерывания приёма не чаще раза в 100 мкс прерывание на каждый кадр TCP 4,01 с вместо 4,88
9595afb, f37c5a6 offload в обе стороны работу гостя над каждым кадром, а потом сорок с лишним кадров на одну отправку httpd 0,47 с вместо 1,63–1,68
23fdb63 отображённые кольца отображение страницы и сброс её записи в TLB при каждом обращении к кольцу TCP 2,72 с вместо 4,15; httpd 0,29 с вместо 0,47
78042cf сдвинутое окно будит первый CPU ответ в 10 мс раз в несколько сотен пингов 2400 пингов, не дольше 0,28 мс
8aa5167 своя задача для NIC ничего: в слияние не попала httpd 0,42–0,59 с против 0,21

Posted interrupts: IPI без выхода

С виртуализацией APIC процессор VT-x сам доставляет прерывания гостю, чей локальный APIC работает в режиме x2APIC, и сам выполняет его EOI — без выходов. В «интеловском» раунде это включалось только по просьбе гостя (apicv): IPI от другого CPU или MSI от устройства всё равно попадали в почтовый ящик, и CPU, находящийся в госте, приходилось выбивать оттуда «пинком», чтобы их вручить. Пинок — это IPI плюс выход, и он дороже того выхода по EOI, который исчез. С ним гости выходили на 25–50% реже, а в среднем всё равно получалось 5,88 с против 5,70 без него.

Posted interrupts убирают пинок (89a4c5a). У каждого CPU гостя есть 64-байтный дескриптор, на который указывает его VMCS, — PostedInterrupts: бит запроса на каждый вектор и бит ON — «уведомление уже в пути». Отправитель фиксированного прерывания по фронту ставит в нём свой бит:

    pub fn post(&self, vector: u8) -> bool {
        self.pir[usize::from(vector / 64)].fetch_or(1 << (vector % 64), Ordering::SeqCst);
        self.control.fetch_or(PI_ON, Ordering::SeqCst) & PI_ON == 0
    }

Если ON был сброшен, а получатель сейчас в госте, отправитель шлёт его хостовому CPU вектор уведомления 0xFC, и процессор сам переносит отправленное в VIRR и доставляет прямо в госте. Прерывания по уровню, NMI, INIT и start-up IPI по-прежнему идут через почтовый ящик.

Платить за это пришлось в hvarch. Posted interrupts требуют, чтобы каждый выход по внешнему прерыванию подтверждал это прерывание, — иначе хост забрал бы себе уведомление, адресованное гостю. Значит, собственные прерывания хоста теперь приходят на выходе уже подтверждёнными и никуда не доставленными, и hvarch доставляет каждое заново — инструкцией int с его вектором через IDT хоста, когда никакое per-CPU-состояние уже не заимствовано: обработчик может переключить задачу. Это шесть новых мест с unsafe в hvarch, было 195, стало 201; в hv — всём, что выше, — их по-прежнему четыре.

Десять прогонов с ними и десять без, по очереди: 3,66–5,89 с, в среднем 4,85, против 5,58–5,79 с и 5,72 — быстрее в 9 случаях из 10, а выходов 0,67–1,58 млн против 2,3–2,6 млн. Доставка прерываний самим процессором теперь включена по умолчанию везде, где есть posted interrupts (noapicv её выключает), а новый встроенный гость posted следит, чтобы так и оставалось: его второй CPU шлёт первому тысячу IPI, пока тот крутится в цикле, и на EX44 первый CPU вышел из гостя 9 раз на всю тысячу.

Halt polling оказался модерацией прерываний — случайной

От «интеловского» раунда осталась загадка: остановленный по HLT vCPU, который перед сном 200 мкс следил за своим почтовым ящиком, ускорял эту передачу с 5,7 до 3,9 с, а адаптивное ожидание на манер KVM давало 5–7 с. Разбор с включёнными posted interrupts, по два прогона (e98a4de), показал, что настоящий halt polling — 200 мкс внимания и к почтовому ящику, и к doorbell — медленнее: 6,27 и 6,48 с против 3,94 и 5,08 без ожидания. Он вручает прерывание каждого кадра сразу, получатель берёт по одному кадру на прерывание и подтверждает каждый кадр или каждый второй — 265 000–277 000 ACK против 111 000–151 000, — а отправитель, получая ответы так часто, шлёт больше кадров, и они мельче.

Ожидание, которое выглядело выигрышем, смотрело только на почтовый ящик. На остановленном первом CPU гостя-получателя оно не отвечало на doorbell, которым коммутатор зовёт на работу с устройствами, пока не истекали 200 мкс, — кадры ждали и ложились в кольцо пачкой: 18,9 на прерывание, 41 000 ACK, 3,94 с. Это и была модерация прерываний, только случайная. Если то же ожидание не крутить, а спать, выходило 12,5–14,6 с: сон на хосте заканчивался на его следующем тике в 100 Гц. Цифры просили модерацию, но намеренную, — и таймер точнее тика.

Таймер точнее тика

Таймер каждого CPU — локального APIC на x86-64, generic timer на arm64 — тикал периодически с частотой 100 Гц, и задача, заблокированная до дедлайна, запускалась на первом тике после него: сон на миллисекунду длился десять. Так же ждёт следующего фронта таймера своего гостя остановленный vCPU, и usleep 1000 в госте занимал 10 мс. Теперь таймер one-shot (dc416b9): он взводится на ближайшее из двух — следующий тик или самый ранний дедлайн задачи, спящей на этом CPU («The local timer»). Самотест при каждой загрузке на каждом CPU 21 раз засыпает на миллисекунду и валит загрузку, если медиана больше 5 мс. Под QEMU он намеряет 1,007–1,07 мс с HVF и 1,1–2,4 мс с TCG, а без взведения таймера — 10,0 мс: это контроль.

Затем гипервизор перед каждым входом взводит таймер своего хостового CPU на следующий фронт таймера гостя (415d29d), так что и гость, который считает без единого выхода, будет вовремя вынут из гостевого режима (run.rs):

            let edge = pit_edge.unwrap_or(u64::MAX)
                .min(gc.lapic.next_timer_ns().unwrap_or(u64::MAX))
                .min(devices_due.unwrap_or(u64::MAX));
            if edge != u64::MAX {
                kcore::cpu::timer_at(edge);
            }

Дедлайн, который уже прошёл, отвергается: такой таймер срабатывал бы снова при каждом входе. В гостях с Linux 6.18 сто вызовов usleep 1000 заняли 0,16 с вместо 0,99 — и у остановленного гостя, и у гостя, занятого циклом на процессоре. TCP-пара не сдвинулась (3,68–5,36 с, четыре прогона): этот шаг готовил следующий.

У настоящей миллисекунды есть и своя цена (4696f48): блокирующие вызовы TCP опрашивают состояние со сном в 1 мс, и простаивающий nos с sshd и двумя открытыми сессиями просыпался 2220 раз в секунду, занимая 1,2% своих 20 CPU. Сон, удваивающийся от 1 до 10 мс, превратил это в 741 пробуждение и 0,4%; правильное решение коммит называет сам — ожидание, о конце которого сообщает путь приёма.

Прерывания приёма — не чаще раза в 100 мкс

С таким таймером модерация — это несколько строк в устройстве (4445a2d). Как в interrupt throttling настоящих сетевых карт, кадр сразу кладётся в буферы гостя, но прерывание ждёт, пока с предыдущего не пройдёт заданное время, и тогда одно прерывание забирает всё накопившееся (net.rs):

    fn rx_interrupt(&mut self, mem: &GuestMemory, now: u64) -> Raise {
        if !self.rx_owed || now < self.rx_quiet_until {
            return Raise::default();
        }
        self.rx_owed = false;
        if !self.transport.queue(RX_QUEUE).map_or(false, |q| q.wants_interrupt(mem)) {
            return Raise::default();
        }
        if self.rx_quiet_ns != 0 {
            self.rx_quiet_until = now.saturating_add(self.rx_quiet_ns);
            self.backend.quiet_until(self.rx_quiet_until);
        }
        self.transport.interrupt(RX_QUEUE)
    }

Зная конец окна, коммутатор до тех пор никого не будит ради кадра, если только очередь порта не заполнена наполовину, а vCPU, который делает работу устройств, сам возвращается к концу окна по one-shot-таймеру. Окно задаёт rxmod=US. По три прогона на каждое окно, по очереди:

окно кадров на прерывание ACK от получателя 500 МБ
нет 1,12–1,17 137 000–176 000 4,57–5,15 с (4,88)
25 мкс 2,6–2,8 140 000–168 000 4,47–5,22 с (4,72)
50 мкс 4,7–5,0 102 000–144 000 4,13–4,80 с (4,57)
100 мкс 9,7–10,2 63 000–87 000 3,94–4,07 с (4,01)
200 мкс 20,0–20,5 33 000–47 000 3,70–3,86 с (3,77)

При 200 мкс ещё быстрее, но страдает задержка: сотня мелких HTTP-запросов, каждый по своему соединению, заняла 0,07 с без окна, 0,05–0,07 с при 100 мкс и 0,08–0,09 с при 200. Поэтому по умолчанию — 100 мкс. Цель фаззера для сетевой карты, теперь с модерацией на большинстве входов, сразу нашла, что снятая драйвером очередь навсегда оставалась должна прерывание: вечный долг и цикл исполнения, который крутился бы без пауз.

Offload в обе стороны

Virtio-net гостя теперь предлагает VIRTIO_NET_F_CSUM и HOST_TSO4 (9595afb): драйвер может оставить контрольные суммы устройству и отдать ему TCP поверх IPv4 одной отправкой до 64 КиБ, а устройство нарежет её на кадры не длиннее 1514 байт ещё до коммутатора — так что стек nos, NAT и другие гости по-прежнему видят целые кадры с правильными суммами. Каждое смещение и каждую длину, которые сообщает драйвер, устройство сверяет с кадром до использования, а одно ограничение защищает хост, а не гостя (offload.rs):

/// The least segment size a send may ask to be cut by: a send is then 256
/// frames at most, a ring's worth, where a byte a segment would make one
/// notify 65,000 frames on the switch -- free to the guest that asks, and
/// costing the host's CPU and every inbox on the way. TCP on the switch's
/// MTU cuts at 1,448.
pub const MIN_SEGMENT: usize = 256;

Веб-сервер: 1,11 и 1,12 с вместо 1,63 дважды, 8400 отправок нарезано на 362 000 кадров, а выходов почти столько же — устройство сэкономило гостю его собственную работу над каждым кадром. Затем приёмная сторона (f37c5a6): GUEST_CSUM, GUEST_TSO4 и mergeable buffers. Между двумя гостями коммутатор больше не режет и не досчитывает то, чего не нужно: гость, чей драйвер это принимает, получает кадр с недосчитанной суммой таким, каким его оставил отправитель, — Linux считает его проверенным, как кадры своего loopback, — и отправку до 64 КиБ целиком, разложенную по его буферам. Веб-сервер: 0,47 с дважды против 1,63 и 1,68; скачивающий гость принял 8500 отправок вместо 362 000 кадров. В hv, который всё это разбирает, мест с unsafe осталось четыре.

Зато dd | nc замедлился на 7%: 4,45 с против 4,15, по пять прогонов. Если включать возможности по одной, большая часть потерь приходилась на контрольную сумму на стороне отправителя, а выходов и IPI было столько же: драйвер Linux, оставляющий суммы устройству, заодно включает scatter-gather — три-четыре дескриптора на кадр вместо двух, — а каждый дескриптор и буфер, который читало устройство, был гостевой страницей, которую хост отображал и снимал. Коммит записал это как самую вероятную причину, а следующий её измерил.

Кольца, отображённые на всё время жизни очереди

Каждое обращение к памяти гостя было копированием через собственный слот CPU в окне ядра: страницу отобразить, байты скопировать, слот снять и сбросить его запись в TLB — по дюжине раз на кадр только для колец сетевой карты virtio. Теперь кольца очереди — три страницы на 256 элементов — отображены, пока очередь поднята (23fdb63), но читаются и пишутся по-прежнему по одному значению: volatile-загрузка или запись по проверенному выровненному смещению, индекс — с acquire или release, и ни одной ссылки в память гостя. Отображение, kcore::frame::Mapped, разделяет владение фреймами своего региона, так что ни один из них не освободится, пока отображён; шесть мест с unsafe, которых это потребовало, лежат в kcore — в Run и в самом отображении, — каждое со своим обоснованием.

Две вещи окно делало случайно, и теперь их пришлось делать намеренно. Его INVLPG — сериализующая инструкция, и она стояла между публикацией used-индекса устройством и чтением флагов драйвера (virtio.rs):

    pub fn wants_interrupt(&self, mem: &GuestMemory) -> bool {
        core::sync::atomic::fence(core::sync::atomic::Ordering::SeqCst);
        self.rings(mem).word(self.avail_at()).map_or(true, |flags| flags & AVAIL_NO_INTERRUPT == 0)
    }

Без барьера загрузка устройства может обогнать его же запись, и драйвер ждёт прерывания, которое устройство решило не посылать. А снятие отображения сбивает его адрес в TLB всех CPU хоста, так что гость, раз за разом пересоздающий очереди, мог бы ценой одного выхода прерывать весь хост. Поэтому устройство отображает кольца восьми очередей, а дальше — одной в секунду; сверх этого кольца копируются, как раньше.

По пять прогонов: dd | nc 2,72 с вместо 4,15, а с offload — 2,83 вместо 4,45, так что их цена ушла в разброс; веб-сервер 0,29 с вместо 0,47.

Пинг в 10 мс

Сборка с отображёнными кольцами показала то, что прячут средние: пинг между двумя гостями занимал 0,2 мс, но раз в несколько сотен — 10,2 мс. Кадры, пришедшие внутри окна модерации, никого не будят: цикл первого CPU, который делает обходы устройств, сам возвращается к концу окна — он же и поднял прерывание, которое это окно открыло. Но Linux пополняет кольцо приёма с того CPU, на котором принимает прерывание, и notify этого CPU сам поднимает следующее прерывание, открывая окно, о котором первый CPU не знает. Тот спал с концом старого окна в голове и досыпал до своего потолка в 10 мс, а кадры ждали в его очереди. Теперь notify с другого CPU, сдвинувший окно, будит первый (78042cf): 2400 пингов, ни одного медленного, в среднем 0,19 мс. Фаззер знал окно каждого порта, но не окно, открытое за спиной у этого vCPU.

Своя задача для сетевой карты: измерили и отложили

Всю работу устройств делает задача первого CPU, и вынести оттуда сетевую карту — очевидный следующий рычаг. Мы попробовали (8aa5167): сетевые карты гостя обслуживает отдельная задача на свободном потоке его ядра и опрашивает кольца при выключенных notify драйвера. Но по умолчанию гости получают ядра целиком, так что свободный поток остаётся только у гостя с одним CPU — а у такого гостя нет локального APIC: его прерывания идут через 8259 и всё равно выводят его из гостевого режима. Быстрее не стало: dd | nc 2,7–2,8 с против 2,66, httpd 0,42–0,59 с против 0,21. В заметке записано, где это ещё может окупиться, — у гостей с MSI-X и лишним CPU, — а код в слияние не попал.

Что осталось

Posted interrupts в nos работают только под VT-x; всё остальное живёт в устройствах и цикле исполнения, общих для обоих бэкендов, хотя мерилось всё на машине Intel. Открытыми в списке Speed остаются доставка прерываний самим процессором для гостей в режиме xAPIC и AVIC от AMD и сокращение выходов на запрос (EVENT_IDX и NO_NOTIFY пока нет), а замеров fio и iperf внутри гостя не было вовсе.

Каждый шаг проходил гейты при вливании: фаззер гипервизора — 1,2 млн входов без единой находки; обе архитектуры собраны, загружены smoke-тестом и прогнаны через hv-test — и обычной сборкой, и с UBSAN=1 RUSTUB=1; hv-linux-test — вложенно на EX44. Остальная картина — на странице гипервизора.