Особенно ревностные фанаты C и C++ любят говорить, что эти языки позволяют им все контролировать. Даже малейшие прикосновения к памяти. Нужно просто научиться правильно пользоваться указателями и знать, как устроена память и как работают компьютеры! Байтики они всегда байтики... Вот только злобные разработчики компиляторов понапридумывают своих странных оптимизаций, которые ломают наш прекрасный и 100% правильный код! А когда не ломают, то ничего не оптимизируют!
При обсуждении правил алиасинга и неопределенного поведения при их нарушении, обычно демонстрируют какую-то странную не очень реалистичную функцию от двух указателей и показывают, какой ужас, ее результат не соответствует ожиданиям при включении оптимизаций. Я, пожалуй, отойду от этой традиции и начну с функции, у которой никаких проблем нет.
// Не владеющий view над непрерывным массивом,
// например, для итерации по столбцу матрицы
template <class T>
struct stride_view {
T* data;
size_t step;
size_t len;
T& operator[](int idx) {
return this->data[idx * step];
}
};
template <class T>
struct Data {
stride_view<T> data;
int counter;
};
void process(Data<uint32_t>* data, int idx) {
data->data[idx] -= 1;
if (data->data[idx] == 0) {
data->counter -= 1;
}
}
void process(Data<uint64_t>* data, int idx) {
data->data[idx] -= 1;
if (data->data[idx] == 0) {
data->counter -= 1;
}
}
Совершенно нормальные функции. Мы даже исполнять их не будем. Давайте их просто скомпилируем (GCC 14):
g++ -std=c++26 -O3
И посмотрим на сгенерированный код:
process(Data<unsigned int>*, int):
movsx rsi, esi
imul rsi, QWORD PTR [rdi+8]
mov rax, QWORD PTR [rdi]
lea rdx, [rax+rsi*4]
sub DWORD PTR [rdx], 1
jne .L1
sub DWORD PTR [rdi+24], 1
.L1:
ret
process(Data<unsigned long>*, int):
mov rdx, QWORD PTR [rdi+8]
movsx rsi, esi
mov rax, QWORD PTR [rdi]
sal rsi, 3
imul rdx, rsi
sub QWORD PTR [rax+rdx], 1
imul rsi, QWORD PTR [rdi+8]
cmp QWORD PTR [rax+rsi], 0
jne .L4
sub DWORD PTR [rdi+24], 1
.L4:
ret
И вот же странная картина! Версия с uint32
явно оптимизирована чуть получше версии с uint64
!
// можно заметить, что код operator[] (конкретно умножение на шаг) присутствует дважды
// индекс пересчитывается дважды и обращений к памяти два!
imul rdx, rsi
sub QWORD PTR [rax+rdx], 1
imul rsi, QWORD PTR [rdi+8]
cmp QWORD PTR [rax+rsi], 0
Ну, мы так и написали же в коде...
Да. Вот только в версии с uint32
обращение лишь одно. А в коде на C++ же два!
imul rsi, QWORD PTR [rdi+8]
mov rax, QWORD PTR [rdi]
lea rdx, [rax+rsi*4]
sub DWORD PTR [rdx], 1
Обращения к памяти в основном очень дороги. А значит от оптимизации их все будут только в плюсе! Зачем перечитывать повторно значение из памяти в регистр, если нам заранее известно, что оно там не поменялось?!
Но на основе чего можно получить такую информацию, чтоб иметь право выполнить оптимизацию?! Языки C и C++ же низкоуровневые. Полный контроль! Можно делать что угодно с памятью и указателями...
НЕТ. НЕЛЬЗЯ. Поприветствуем правила строгого алиасинга!
Если у вас есть указатели двух разных типов A* a
и B* b
, то запись значений через указатель a
не влияет на чтение значений через указатель b
, во всех случаях, кроме нескольких исключений (алиасинг):
A
иB
— это совместимые, signed/unsigned версии одного и того же типаA
— это тип, совместимый с каким-либо из подобъектов внутриB
: элемент структуры или объединения, элемент массиваA
илиB
—char
,unsigned char
илиstd::byte
. Этот вариант существует как раз для того чтоб можно было работать с сырыми байтиками. Заметьте, чтоsigned char
не считается.
В C (не в C++, но компиляторы поддерживают!) допустимо еще:
A
иB
— это соместимые структуры/объединения: у них совпадает размер, порядок и имена полей и типы полей совместимы. Напримерstruct Vector { int32_t x; int32_t y; }
и structPoint { uint32_t x; uint32_t y; }
Ну, звучит не страшно. Даже логично и правильно. Если у меня есть массив целых чисел, а еще рядом массив чисел с плавающей точкой, то в корректной программе, от изменения числа в первом массиве во втором ничего не должно поменяться.
Теперь можно вернуться к примеру и понять, что пошло не так
struct stride_view<uint32_t> {
uint32_t* data;
size_t step;
size_t len;
uint32_t& operator[](int idx) {
return this->data[idx * step];
}
};
...
void process(Data<uint32_t>* data, int idx) {
data->data[idx] -= 1; // ссылка uint32_t&. Запись через нее.
// внутри stride_view нет полей типов int32.
if (data->data[idx] == 0) { // Запись выше не влияет на индекс и можно оптимизировать
data->counter -= 1;
}
}
struct stride_view<size_t> {
size_t* data; // !!!
size_t step;
size_t len;
T& operator[](int idx) {
return this->data[idx * step];
}
};
void process(Data<size_t>* data, int idx) {
data->data[idx] -= 1; // ссылка size_t&. Запись через нее.
// step имеет тип size_t. Ссылка теоретически может алиасить это поле!
if (data->data[idx] == 0) { // Запись выше влияет на индекс. Нельзя оптимизировать!
data->counter -= 1;
}
}
Пытливый читатель, наверное, уже догадался, что случай с указателем char*
совершенно восхитительно запрещает оптимизацию почти всегда. Ведь он может алиасить что угодно!
Строгий алиасинг это особенность не только С и C++. Rust, например, тоже им следует, но еще более строго, включающая информацию о уникальных и разделяемых ссылках
pub struct StrideView<'a, T> {
pub data: &'a mut [T],
pub step: usize,
}
impl <'a, T> StrideView<'a, T> {
unsafe fn get<'b>(&'b mut self, idx: usize) -> &'b mut T {
self.data.get_unchecked_mut(self.step * idx) // unsafe, нас не интересует код обработки паник
}
}
pub struct Data<'a, T> {
pub data: StrideView<'a, T>,
pub counter: i32,
}
#[no_mangle]
pub unsafe fn process(data: &mut Data<'_, usize>, idx: usize) {
*data.data.get(idx) -= 1; // ссылка &mut usize
// она не может алиасить поле step
// так как иначе это бы означало, что data содержит ссылку на саму себя
// что невозможно для обычных ссылок в safe Rust
if (*data.data.get(idx) == 0) { // можно оптимизировать!
data.counter -= 1;
}
}
// https://godbolt.org/z/dc8Mcajhe
process:
imul rsi, qword ptr [rdi + 16]
mov rax, qword ptr [rdi]
dec qword ptr [rax + 8*rsi]
je .LBB0_1
ret
.LBB0_1:
dec dword ptr [rdi + 24]
ret
Ну хорошо, существует такое правило. Оптимизировать позволяет. Отлично. А где неопределенное поведение?
А оно начинается тогда, когда разработчик творит безобразие с преобразованием указателей!
Вот теперь можно и классическую странную функцию показать!
#include <stdio.h>
int foo(int* x, short* y) {
*x = 5;
*y = 10;
return *x;
}
int main() {
int f = 0;
printf("%d\n", foo(&f, (short*)&f));
}
В зависимости от уровня оптимизаций, на little endian платформах, результат будет 5 или 10.
Функция, конечно, совершенно надуманная, но фокус в том, что она иллюстрирует простейший пример так называемого type punning — обращения с объектом одного типа, как с совершенно другим.
А вот он уже в реальных программах распространен! Быстрый обратный квадратный корень из Quake III — классический пример
float Q_rsqrt( float number )
{
int32_t i;
float x2, y;
const float threehalfs = 1.5F;
x2 = number * 0.5F;
y = number;
i = * (int32_t * ) &y; // evil floating point bit level hacking
i = 0x5f3759df - ( i >> 1 ); // what the fuck?
y = * ( float * ) &i;
y = y * ( threehalfs - ( x2 * y * y ) ); // 1st iteration
// y = y * ( threehalfs - ( x2 * y * y ) ); // 2nd iteration, this can be removed
return y;
}
Такой лобовой type punning нарушает предположения строгого алиасинга. Нарушение объявлено неопределенным поведением. И результат можно получить самый странный.
Type punning очень распространен в библиотеках сериализации и десериализации для эффективного разбора сырых байтов. В реализации сетевых протоколов: например, всякие структуры in_addr используют union
для type punning.
В ядре Linux также можно найти примеры.
Вся эта красота в любой момент может сломаться, если она сделана неправильно. Поэтому у GCC и Clang есть флаг -fno-strict-aliasing
, чтоб отключить оптимизации на основе строгого алиасинга и позволить себе стрелять по ногам чуть более предсказуемо.
Туpe punning с помощью reinterpret_cast
— почти всегда выводит на поле неопределенное поведение.
Язык C (не C++!) позволяет выполнять type punning с помощью union.
В C++ до C++20 нужно было использовать memcpy
и... копировать. Компиляторы при этом часто способны такие копии ради type punning оптимизировать и убирать. В C++20 добавили std::bit_cast
для той же цели.
В С++23 еще появится start_lifetime_as
— который поможет для наиболее частого случая type punning: интерпретировать массив принятых байт (например, по сети) как осмысленную структуру/массив структур.
Последнее что нужно отметить: преобразование указателей само по себе не является неопределенным поведением. Так что, к примеру, стандартная цепочка T*
-> void*
-> T*
при передаче объектов и функций между границами библиотек не нарушает правила алиасинга.
A вот использование указателя A*
, чтоб через него прочитать/изменить объект другого типа B
— проблемы начинаются здесь.
- https://en.cppreference.com/w/cpp/language/reinterpret_cast
- https://en.cppreference.com/w/c/language/type#Compatible_types
- https://en.wikipedia.org/wiki/Fast_inverse_square_root
- https://gist.github.com/shafik/848ae25ee209f698763cffee272a58f8
- https://www.yodaiken.com/2018/06/07/torvalds-on-aliasing/
- https://en.cppreference.com/w/cpp/memory/start_lifetime_as