Могут ли оптимизированные сборки и JIT-компиляция создавать проблемы при изменении переменной через интервал ?

#c# #memory #compilation

#c# #память #Сборник

Вопрос:

Предположим, я использую MemoryMarshal.CreateSpan для доступа к байтам локального типа значения, например, следующий (не очень полезный) код:

 using System;
using System.Runtime.InteropServices;

// namespace and class boilerplate go here

private static void Main()
{
    int value = 0;
    Span<byte> valueBytes = MemoryMarshal.AsBytes(MemoryMarshal.CreateSpan(ref value, 1));

    var random = new Random();
    while (value >= 0) // the check in question
    {
        random.NextBytes(valueBytes);
        Console.WriteLine(value);
    }
}
  

Хотя этот код работает по назначению, гарантированно ли указанная проверка сохранится при компиляции в IL и JIT-компиляции без оптимизации в true , учитывая, что переменная value не изменяется в цикле, кроме как косвенно через valueBytes span? Могу ли я полагаться на чтение, value дающее мне то, что записано при записи в valueBytes , или это может быть уязвимо для изменения порядка? Или я просто параноик, потому что недавно немного увлекся C ?

(Обратите внимание, что я знаю, что существуют другие способы достижения желаемого эффекта от приведенного выше кода, это не вопрос о том, как получить полноразмерное 32-разрядное случайное целое число или проблема XY с каким-то большим приложением, в которое я пытаюсь поместить этот код, такого большого приложения не существует)

Комментарии:

1. Отличный вопрос! Я думаю, это также можно было бы расширить до того, как возможное изменение порядка команд может или не может повлиять на такой сценарий (например, изменение значения int через span, а затем обращение к значению int впоследствии. Гарантируется ли или нет, что компилятор C # или JITer обнаружит такую ситуацию и предотвратит изменение порядка доступа на запись в span после доступа к переменной value?)

2. @elgonzo — отличная мысль, я обновил вопрос, чтобы включить это.

3. Похоже, вас задело правило определения типов в C и C . Они ужасно злоупотребляли этим. C # документирует намерение исходного правила, приведение от меньшего к большему типу с большим требованием выравнивания не определено. Это просто жесткое поведение процессора, которое имело гораздо большее значение во времена RISC. Злоупотребление правилом, чтобы прекратить проверку псевдонимов указателей, по-настоящему злое поведение, это не проблема. Раздел 23.5.1 стандарта явно разрешает такое преобразование. Обратите внимание, что адресация на уровне байтов, как вы сделали здесь, также определена в C и C .

Ответ №1:

Я думаю, единственный определенный ответ могут дать люди, которые реализуют оптимизацию компилятора, как на Roslyn, так и на RyuJIT сторонах.

Поскольку вы используете .NET Core, вы, конечно, могли бы погрузиться в исходный код и найти ответ самостоятельно. Однако это будет ответом для конкретной версии компилятора.

Посмотрите на сгенерированный IL-код для вашего фрагмента:

 // int value = 0;
ldc.i4.0
stloc.0

// MemoryMarshal.CreateSpan(ref value, 1)
ldloca.s 0
ldc.i4.1
call valuetype System.Span`1<!!0> System.Runtime.InteropServices.MemoryMarshal::CreateSpan<int32>(!!0amp;, int32)

// the rest is omitted
  

Обратите внимание, что ldloca.s код операции. Эта операция загружает адрес локальной переменной в стек вычислений.

Хотя я не могу предоставить вам официальную ссылку, подтверждающую это, но я почти уверен, что компиляторы C # и JIT не будут оптимизировать эту локальную переменную только потому, что был использован ее адрес, поэтому есть вероятность, что эта локальная переменная будет изменена через свой адрес.

Если вы посмотрите на сгенерированный ассемблерный код, вы увидите именно это: локальная переменная есть и помещена в стек, это не переменная только для регистра.

 // int value = 0;
xor         ecx,ecx  
mov         dword ptr [rsp 3Ch],ecx 

WHILE_LOOP_START:
// ... do stuff

// effectively: if (value >= 0) goto WHILE_LOOP_START
cmp         dword ptr [rsp 3Ch],0  
jge         WHILE_LOOP_START  
  

Попробуйте написать какой-нибудь код, который не выдает ldloca.s код операции (например, просто value в цикле), value переменная, скорее всего, станет переменной только для регистра.

Если вы измените свой код таким образом, что value он никогда не будет записан (за исключением инициализации), JIT-компилятор фактически полностью устранит проверку и саму переменную:

 LOOP:

// Console.WriteLine(0)
xor         ecx,ecx  
call        CONSOLE_WRITE_LINE

// while (true)
jmp         LOOP
  

Интересно, однако, что компилятор C # не будет выполнять такую оптимизацию:

 // int value = 0;
ldc.i4.0
stloc.0

br.s WHILE_CHECK

LOOP_START:
// Console.WriteLine(value)
ldloc.0
call void System.Console::WriteLine(int32)

WHILE_CHECK:
// effectively: if (value >= 0) goto LOOP_START
ldloc.0
ldc.i4.0
bge.s LOOP_START
  

Опять же, IL и ассемблерный код в моем ответе зависят от платформы и компилятора (даже от среды CLR). Я не могу предоставить вам подтверждающие документы. Но я почти уверен, что ни один компилятор не будет оптимизировать локальную переменную, адрес которой был получен и, более того, использовался в качестве аргумента при вызове методов / функций.

Возможно, кто-нибудь из команд Roslyn и RyuJIT мог бы дать вам лучший ответ.

Комментарии:

1. Спасибо за информацию, это довольно обнадеживает, что все недавние изменения, похоже, не заимствуют C footgun!

2. @dymanoid верен — поскольку локальный адрес «взят», jit-код должен быть достаточно консервативным и получать обновления. И jit не выполняет никакого устранения неоднозначности псевдонимов на основе типа.