Может ли современное оборудование x86 не хранить в памяти ни одного байта?

Говоря о модели памяти C++ для параллелизма, язык программирования Страуструпа C++, 4-е изд., С. 41.2.1, говорит:

... (как и большинство современных аппаратных средств) машина не может загрузить или сохранить что-либо меньшее, чем слово.

Однако мой процессор x86, которому несколько лет, может хранить и хранит объекты меньше, чем слово. Например:

#include <iostream>
int main()
{
    char a =  5;
    char b = 25;
    a = b;
    std::cout << int(a) << "\n";
    return 0;
}

Без оптимизации GCC компилирует это как:

        [...]
        movb    $5, -1(%rbp)   # a =  5, one byte
        movb    $25, -2(%rbp)  # b = 25, one byte
        movzbl  -2(%rbp), %eax # load b, one byte, not extending the sign
        movb    %al, -1(%rbp)  # a =  b, one byte
        [...]

Комментарии от меня, но сборка от GCC. Конечно, работает нормально.

Очевидно, я не понимаю, о чем говорит Страуструп, когда объясняет, что аппаратное обеспечение может загружать и хранить не что иное, как слово. Насколько я могу судить, моя программа только загружает и хранит объекты меньше слова.

Тщательное внимание C++ к недорогим, аппаратно-дружественным абстракциям отличает C++ от других языков программирования, которые легче освоить. Поэтому, если у Страуструпа есть интересная мысленная модель сигналов в автобусе, или есть что-то еще в этом роде, то я хотел бы понять модель Страуструпа.

О чем говорит Страуструп, пожалуйста?

БОЛЬШЕ ЦИТАТА С КОНТЕКСТОМ

Вот цитата Страуструпа в более полном контексте:

Рассмотрим, что может произойти, если компоновщик выделит [переменные char типа как] c а также b в одном и том же слове в памяти и (как и большинство современных аппаратных средств) машина не может загрузить или сохранить что-либо меньшее, чем слово.... Без четко определенной и разумной модели памяти поток 1 может прочитать слово, содержащее b а также c, менять c и запишите слово обратно в память. В то же время поток 2 может сделать то же самое с b, Затем, какой бы поток не смог прочитать слово первым, и какой поток сумел записать свой результат обратно в память, последний определит результат....

ДОПОЛНИТЕЛЬНЫЕ ЗАМЕЧАНИЯ

Я не верю, что Страуструп говорит о строках кэша. Даже если бы он, насколько мне известно, протоколы когерентности кэша бы прозрачно справились с этой проблемой, за исключением, может быть, во время аппаратного ввода-вывода.

Я проверил технические характеристики моего процессора. Электрически мой процессор (Intel Ivy Bridge), кажется, обращается к памяти DDR3L по какой-то схеме 16-битного мультиплексирования, поэтому я не знаю, о чем идет речь. Мне не ясно, что это имеет непосредственное отношение к точке зрения Страуструпа.

Страуструп - умный человек и выдающийся ученый, поэтому я не сомневаюсь, что он думает о чем-то разумном. Я сбит с толку.

Смотрите также этот вопрос. Мой вопрос напоминает связанный вопрос несколькими способами, и ответы на связанный вопрос также полезны здесь. Тем не менее, мой вопрос касается также модели оборудования / шины, которая мотивирует C++ таким, какой он есть, и заставляет Страуструпа писать то, что он пишет. Я не ищу ответ просто относительно того, что формально гарантирует стандарт C++, но также хочу понять, почему стандарт C++ это гарантирует. Какова основная мысль? Это тоже часть моего вопроса.

6 ответов

Решение

Я не думаю, что это очень точное, ясное или полезное утверждение. Было бы точнее сказать, что современные процессоры не могут загружать или хранить что-либо меньшее, чем строка кэша. (Хотя это не так для областей без кэширования, например, для MMIO.)

Вероятно, было бы лучше просто сделать гипотетический пример, чем предполагать, что реальное оборудование похоже на это. Но если мы попытаемся, мы сможем найти интерпретацию, которая не столь очевидна или совершенно неправильна, что могло быть тем, о чем думал Страуструп, когда писал это, чтобы представить тему моделей памяти. (Извините, этот ответ такой длинный; в итоге я много писал, догадываясь, что он имел в виду, и о смежных темах...)

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


Я думаю, что Страуструп говорит о том, как центральные процессоры работают внутри для реализации инструкций байтового хранилища. Он предлагает, чтобы ЦП без четко определенной и разумной модели памяти мог реализовать хранилище байтов с неатомарным RMW содержащего слова в строке кэша или в памяти для ЦП без кэша.

Даже это более слабое утверждение о внутреннем (не видимом извне) поведении не соответствует действительности для большинства высокопроизводительных процессоров, включая современный x86. Современные процессоры Intel не имеют штрафов за пропускную способность для хранилищ байтов или даже для невыровненных хранилищ слов или векторов, которые не пересекают границы строки кэша. Если бы какой-либо из них должен был выполнить цикл RMW, поскольку хранилище фиксировало кэш L1D, это мешало бы пропускной способности нагрузки.

Alpha AXP, высокопроизводительный RISC-дизайн 1992 года, известный (и единственный среди современных ISA не DSP), опускал инструкции загрузки / сохранения байтов до Alpha 21164A (EV56) в 1996 году. По-видимому, они не считали word-RMW жизнеспособным вариантом для реализации хранилищ байтов, потому что одним из упомянутых преимуществ для реализации только 32-битных и 64-битных выровненных хранилищ была более эффективная ECC для L1D-кэша. "Традиционный SECDED ECC потребовал бы 7 дополнительных бит для 32-битных гранул (22% служебных данных) против 4 дополнительных бит для 8-битных гранул (50% служебных данных)". (В ответе Пола А. Клейтона о адресации слова и байта есть и другие интересные особенности компьютерной архитектуры.) Если бы хранилища байтов были реализованы с помощью word-RMW, вы все равно могли бы выполнять обнаружение / исправление ошибок с гранулярностью слова.

По этой причине современные процессоры Intel используют четность (не ECC) в L1D. См. Этот раздел вопросов и ответов об аппаратном (а не) устранении "хранилищ без вывода сообщений": проверка старого содержимого кэша перед записью, чтобы избежать пометки строки как грязной, если она соответствует, потребует RMW вместо просто хранилища, и это является серьезным препятствием.

Я предполагаю, что другие (не x86) современные конструкции ЦП не рассматривали RMW как вариант для фиксации байтовых хранилищ в L1D-кэше. Word-RMW также не является полезной опцией для хранилищ байтов MMIO, поэтому если у вас нет архитектуры, в которой не требуются хранилища подслов для ввода-вывода, вам понадобится какая-то специальная обработка для ввода-вывода (например , разреженный ввод- вывод Alpha) Пространство, в котором загрузка слов / хранилища были сопоставлены с загрузкой байтов / хранилищами, чтобы можно было использовать обычные PCI-карты вместо необходимости в специальном оборудовании без регистров байтового ввода-вывода).

Как указывает @Margaret, контроллеры памяти DDR3 могут хранить байты, устанавливая управляющие сигналы, которые маскируют другие байты пакета. Те же механизмы, которые передают эту информацию контроллеру памяти (для не кэшированных хранилищ), могут также передавать эту информацию вместе с загрузкой или записью в пространство MMIO. Таким образом, существуют аппаратные механизмы для реального создания байтового хранилища даже в пакетно-ориентированных системах памяти, и весьма вероятно, что современные процессоры будут использовать его вместо реализации RMW, потому что это, вероятно, проще и намного лучше для корректности MMIO.


Следующий абзац Страуструпа

"Модель памяти C++ гарантирует, что два потока выполнения могут обновлять и получать доступ к отдельным ячейкам памяти, не мешая друг другу. Это именно то, чего мы наивно ожидали. Задача компилятора - защитить нас от иногда очень странного и тонкого поведения современное аппаратное обеспечение. Как комбинация компилятора и аппаратного обеспечения достигает того, что зависит от компилятора..."

Поэтому, очевидно, он думает, что настоящее современное оборудование не может обеспечить "безопасную" загрузку / хранение байтов. Люди, которые разрабатывают аппаратные модели памяти, соглашаются с людьми на C/C++ и понимают, что инструкции по хранению байтов не будут очень полезны для программистов / компиляторов, если они смогут наступить на соседние байты.

Все современные (не DSP) архитектуры, за исключением раннего Alpha AXP, имеют инструкции по хранению и загрузке байтов, и AFAIK все они архитектурно определены, чтобы не влиять на соседние байты. Однако они достигают того, что в аппаратном обеспечении программное обеспечение не должно заботиться о корректности. Даже в самой первой версии MIPS (в 1983 году) были загружены / сохранены байтовые и полусложные слова, и это очень ориентированный на слова ISA.

Однако на самом деле он не утверждает, что большинству современного оборудования нужна какая-то специальная поддержка компилятора для реализации этой части модели памяти C++, просто что некоторые могут. Возможно, он действительно говорит только о адресно-ориентированных DSP в этом втором абзаце (где реализации на C и C++ часто используют 16- или 32-битные char как именно тот обходной путь компилятора, о котором говорил Страуструп.)


Большинство "современных" процессоров (включая все x86) имеют кэш L1D. Они будут извлекать целые строки кэша (обычно 64 байта) и отслеживать грязные / не грязные данные для каждой строки кэша. Таким образом, два смежных байта в значительной степени совпадают с двумя смежными словами, если они оба находятся в одной строке кэша. Запись одного байта или слова приведет к извлечению всей строки и, в конечном итоге, обратной записи всей строки. Посмотрите Ульриха Дреппера, что каждый программист должен знать о памяти. Вы правы, что MESI (или производная от MESIF/MOESI) гарантирует, что это не проблема. (Но опять же, это потому, что аппаратное обеспечение реализует разумную модель памяти.)

Хранилище может фиксировать только кэш L1D, пока строка находится в состоянии Modified (MESI). Таким образом, даже если внутренняя аппаратная реализация медленна для байтов и требует дополнительного времени для слияния байта с содержащим словом в строке кэша, это фактически атомарная запись с модифицированным чтением, если она не позволяет строке быть признанной недействительной и повторно -приобретенный между чтением и записью. ( Хотя в этом кеше есть строка в измененном состоянии, ни один другой кеш не может иметь действительную копию). См . Комментарий @ old_timer, в котором говорится об этом (но также и о RMW в контроллере памяти).

Это проще, чем, например, атомная xchg или же add из регистра, который также нуждается в ALU и доступе к регистру, поскольку все задействованные HW находятся на одной и той же стадии конвейера, которая может просто остановиться на дополнительный цикл или два. Очевидно, что это плохо сказывается на производительности и требует дополнительного оборудования, чтобы позволить тому этапу конвейера сигнализировать о его остановке. Это не обязательно противоречит первому утверждению Страуструпа, потому что он говорил о гипотетическом ISA без модели памяти, но это все еще натянуто.

На одноядерном микроконтроллере внутреннее слово-RMW для кэшированных хранилищ байтов было бы более правдоподобным, поскольку не было бы запросов о недействительности, поступающих от других ядер, на которые им пришлось бы откладывать ответ во время атомарного обновления слова кэша RMW, Но это не помогает при вводе / выводе в некэшируемые регионы. Я говорю о микроконтроллере, потому что другие одноядерные процессоры обычно поддерживают несколько SMP-сокетов.


Многие ISA RISC не поддерживают загрузку / сохранение невыровненных слов с помощью одной инструкции, но это отдельная проблема (сложность заключается в обработке случая, когда загрузка занимает две строки кэша или даже страницы, что не может происходить с байтами или выравниванием полуслова). Все больше и больше ISA добавляют гарантированную поддержку для выравнивания загрузки / хранения в последних версиях. (например, MIPS32/64 Release 6 в 2014 году, и я думаю, что AArch64 и последние 32-разрядные ARM).


4-е издание книги было опубликовано в 2013 году, когда Альфа была мертва годами. Первое издание было опубликовано в 1985 году, когда RISC была новой большой идеей (например, Stanford MIPS в 1983 году, согласно временной шкале вычислений HW в Википедии, но "современные" CPU в то время были с байтовой адресацией с байтовыми хранилищами. Cyber ​​CDC 6600 был адресуемый словом и, вероятно, все еще вокруг, но не может быть назван современным.

Даже очень ориентированные на слова RISC-машины, такие как MIPS и SPARC, имеют инструкции по сохранению байтов и загрузке байтов (со знаком или нулевым расширением). Они не поддерживают невыровненную загрузку слов, упрощая кэш (или доступ к памяти, если кеша нет) и порты загрузки, но вы можете загрузить любой отдельный байт одной инструкцией и, что более важно, сохранить байт без перезаписи окружающих байтов.

Я полагаю, что C++11 (который вводит модель памяти с учетом потоков в языке) на Alpha должен был бы использовать 32-битный char если нацелен на версию Alpha ISA без байтовых хранилищ. Или ему пришлось бы использовать программное обеспечение atomic-RMW с LL/SC, когда он не мог доказать, что никакие другие потоки не могут иметь указатель, который позволял бы им записывать соседние байты.


ИДК, как медленные инструкции загрузки / сохранения байтов в любых процессорах, где они реализованы аппаратно, но не так дешево, как загрузки / хранения слов. Байт загружается дешево на x86, пока вы используете movzx/movsx чтобы избежать частичной регистрации ложных зависимостей или слияний. На AMD пре-Рызень, movsx требуется дополнительная операция ALU, но в противном случае расширение нуля / знака обрабатывается прямо в порту загрузки на процессорах Intel и AMD. Основным недостатком x86 является то, что вам нужна отдельная инструкция загрузки, а не использование операнда памяти в качестве источника для инструкции ALU, сохраняя пропускную способность и размер кода переднего плана. RISC-хранилище Для ISA всегда нужны отдельные инструкции по загрузке и хранению. Хранилища x86 байтов не дороже 32-битных.

Как проблема производительности, хорошая реализация C++ для оборудования с медленными байтовыми хранилищами может поставить каждый char в своем собственном слове и по возможности используйте загрузки слов / хранилища (например, для глобальных переменных вне структур и для локальных элементов в стеке). IDK, если какие-либо реальные реализации MIPS / ARM / любого другого имеют медленную загрузку / хранение байтов, но если это так, возможно, gcc имеет -mtune= варианты управления им.

Это не помогает char[] или разыменование char * когда вы не знаете, куда он может указывать. (Это включает volatile char* который вы бы использовали для MMIO.) Таким образом, поставив компилятор + компоновщик char Переменные в отдельных словах - не полное решение, а просто снижение производительности, если настоящие байтовые хранилища работают медленно.


Подробнее об Альфе:

Из Linux Alpha HOWTO.

Когда была представлена ​​архитектура Alpha, она была уникальной среди архитектур RISC для избежания 8-битной и 16-битной загрузки и хранения. Он поддерживал 32-битную и 64-битную загрузку и хранение (длинное слово и четырехзначное слово в номенклатуре Digital). Со-архитекторы (Dick Sites, Rich Witek) обосновали это решение, сославшись на преимущества:

  1. Поддержка байтов в подсистеме кэш-памяти и памяти имеет тенденцию замедлять доступ для 32-битных и 64-битных величин.
  2. Поддержка байтов затрудняет встраивание высокоскоростной схемы исправления ошибок в подсистему кеш / память.

Альфа компенсирует, предоставляя мощные инструкции для управления байтами и группами байтов в 64-битных регистрах. Стандартные тесты для строковых операций (например, некоторые из байтовых тестов) показывают, что Alpha очень хорошо работает с байтовыми манипуляциями.

Мало того, что процессоры x86 способны считывать и записывать один байт, на это способны все современные процессоры общего назначения. Что еще более важно, большинство современных процессоров (включая x86, ARM, MIPS, PowerPC и SPARC) способны атомарно считывать и записывать отдельные байты.

Я не уверен, о чем говорил Страуструп. Раньше было несколько машин с адресацией слов, которые не были способны к 8-битной адресации байтов, например Cray, и, как сказал Питер Кордес, ранние процессоры Alpha не поддерживали загрузку и хранение байтов, но сегодня единственные процессоры, неспособные к байту нагрузки и хранилища - это определенные DSP, используемые в нишевых приложениях. Даже если предположить, что он имеет в виду, что большинство современных процессоров не имеют атомарной байтовой нагрузки и сохраняют данные, это не относится к большинству процессоров.

Тем не менее, простые атомарные загрузки и хранилища не очень полезны в многопоточном программировании. Обычно вам также нужны гарантии порядка и способ сделать операции чтения-изменения-записи атомарными. Другое соображение заключается в том, что, хотя процессор a может иметь инструкции загрузки и хранения байтов, компилятору не требуется их использовать. Например, компилятор может генерировать код, описанный Страуструпом, загружая оба b а также c использование инструкции загрузки одного слова в качестве оптимизации.

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

Кажется, что автор обеспокоен тем, что поток 1 и поток 2 попадают в ситуацию, когда чтение-изменение-запись (не в программном обеспечении, программное обеспечение выполняет две отдельные инструкции размером в байт, где-то внизу логика строки должна выполнять чтение-изменение). изменение-запись) вместо идеального чтения-изменения-записи-чтения-изменения-записи становится записью-чтением-изменением-изменением-записью-записью или каким-либо другим моментом времени, так что оба читают предварительно измененную версию, и последняя, ​​которая записывает, выигрывает. чтение чтение изменение изменение запись запись или чтение изменение чтение изменение запись запись или чтение изменение чтение запись изменение запись.

Задача состоит в том, чтобы начать с 0x1122, и один поток хочет сделать его 0x33XX, а другой хочет сделать его 0xXX44, но, например, с записью чтения-чтения-изменения-записи записи, в результате вы получите 0x1144 или 0x3322, но не 0x3344.

Разумный (системный / логический) дизайн просто не имеет этой проблемы, конечно, не для процессора общего назначения, подобного этому, я работал над проектами с такими проблемами синхронизации, но это не то, о чем мы здесь говорим, совершенно разные конструкции систем. для разных целей. Чтение-изменение-запись не распространяется на достаточно большое расстояние в нормальном дизайне, а x86 - это нормальные проекты.

Чтение-изменение-запись происходило бы очень близко к первой задействованной SRAM (в идеале L1 при обычном запуске x86 с операционной системой, способной запускать многопоточные программы, скомпилированные в C++), и происходило бы в течение нескольких тактовых циклов, поскольку оперативная память на скорости автобуса в идеале. И, как указал Питер, это считается целой строкой кеша, которая испытывает это внутри кеша, а не чтение-изменение-запись между ядром процессора и кешем.

Понятие "одновременно" даже с многоядерными системами не обязательно в одно и то же время, в конце концов вы получаете сериализацию, потому что производительность не основана на параллельности их от начала до конца, она основана на сохранении шин загружен.

Цитата говорит, что переменные размещены в памяти для одного и того же слова, так что это одна и та же программа. Две отдельные программы не собираются использовать такое же адресное пространство. так

Вы можете попробовать это, создать многопоточную программу, которая пишет по адресу 0xnnn00000, а другая записывает по адресу 0xnnnn00001, каждая выполняет запись, затем чтение или несколько записей одного и того же значения, чем одно чтение, проверьте, что чтение было Байт они написали, затем повторяется с другим значением. Позвольте этому бежать некоторое время, часы / дни / недели / месяцы. Посмотрите, отключаете ли вы систему... используйте сборку для фактических инструкций записи, чтобы убедиться, что она выполняет то, что вы просили (не C++ или любой компилятор, который делает или утверждает, что не поместит эти элементы в одно и то же слово). Можно добавить задержки, чтобы увеличить количество кэш-памяти, но это уменьшает ваши шансы "одновременных" коллизий.

Ваш пример, если вы уверены, что вы не находитесь на двух сторонах границы (кеш или другой), такой как 0xNNNNFFFFF и 0xNNNN00000, изолируйте две байтовые записи по адресам, таким как 0xNNNN00000 и 0xNNNN00001, с инструкциями спиной к спине и посмотрите, получите ли вы чтение, чтение, изменение, изменение, запись, запись. Оберните тест вокруг этого, что два значения различны в каждом цикле, вы читаете слово в целом с любой задержкой позже, когда захотите, и проверяете два значения. Повторите для дней / недель / месяцев / лет, чтобы увидеть, если это не удается. Ознакомьтесь с функциями выполнения вашего процессора и микрокода, чтобы увидеть, что он делает с этой последовательностью команд, и при необходимости создайте другую последовательность команд, которая пытается инициировать транзакции в течение нескольких или около того тактовых циклов на дальней стороне ядра процессора.

РЕДАКТИРОВАТЬ

проблема с кавычками состоит в том, что это все о языке и использовании. "как большинство современных аппаратных средств" ставит всю тему / текст в неприглядное положение, это слишком расплывчато, одна сторона может поспорить, все, что мне нужно сделать, - это найти один верный случай, чтобы все остальное было правдой, а также одну сторону Могу поспорить, если я найду один случай, все остальное не соответствует действительности. Используя слово как вид путаницы с этим, как можно скорее выведите из тюрьмы бесплатную карту.

Реальность такова, что значительный процент наших данных хранится в памяти DRAM в 8-битной памяти, просто мы не обращаемся к ним как к 8-битной, обычно мы получаем доступ к 8 из них за раз, 64-битной. Через несколько недель / месяцев / лет / десятилетий это утверждение будет неверным.

Большая цитата гласит: "одновременно", а затем говорит: "прочитайте... сначала, напишите... наконец, хорошо, сначала и наконец, и в то же время не имеют смысла вместе, параллельно или последовательно? Контекст в целом связан с описанными выше вариантами чтения-чтения-изменения-записи-записи, когда у вас последняя запись и в зависимости от того, когда это чтение определяет, произошли обе модификации или нет. Не в то же самое время, когда "как большинство современных аппаратных средств" не имеет смысла, вещи, начинающиеся фактически параллельно в отдельных ядрах / модулях, в конечном итоге становятся сериализованными, если они нацелены на один и тот же триггер / транзистор в памяти, один в конечном итоге должен ждать другого, чтобы идти первым. Основываясь на физике, я не вижу в этом неправильности в ближайшие недели / месяцы / годы.

Это правильно. Процессор x86_64, как и оригинальный процессор x86, не может читать или записывать что-либо меньшее, чем (в данном случае 64-битное) слово из rsp. в память. И он обычно не читает и не записывает меньше, чем целая строка кэша, хотя существуют способы обхода кэша, особенно при записи (см. Ниже).

В этом контексте, однако, Страуструп ссылается на потенциальные гонки данных (отсутствие атомарности на наблюдаемом уровне). Эта проблема корректности не имеет отношения к x86_64 из-за протокола когерентности кэша, о котором вы упомянули. Другими словами, да, процессор ограничен передачей целых слов, но это прозрачно обрабатывается, и вам, как программисту, вообще не нужно беспокоиться об этом. На самом деле язык C++, начиная с C++11, гарантирует, что параллельные операции в разных местах памяти имеют четко определенное поведение, то есть то, которое вы ожидаете. Даже если бы аппаратное обеспечение не гарантировало этого, реализация должна была бы найти способ, генерируя, возможно, более сложный код.

Тем не менее, все еще может быть хорошей идеей сохранить тот факт, что целые слова или даже строки кэша всегда находятся на уровне машины в задней части вашей головы, по двум причинам.

  • Во-первых, и это актуально только для людей, которые пишут драйверы устройств или проектируют устройства, ввод-вывод с отображением в памяти может быть чувствителен к способу доступа к нему. В качестве примера рассмотрим устройство, которое предоставляет 64-битный регистр команд только для записи в физическом адресном пространстве. Тогда может быть необходимо:
    • Отключить кеширование. Недопустимо читать строку кэша, изменять одно слово и записывать обратно строку кэша. Кроме того, даже если бы он был действительным, все равно был бы большой риск того, что команды могут быть потеряны, поскольку кэш-память ЦП не достаточно быстро записывается. По крайней мере, страница должна быть настроена как сквозная, что означает, что запись вступает в силу немедленно. Поэтому запись таблицы страниц x86_64 содержит флаги, которые управляют поведением кэширования ЦП для этой страницы.
    • Убедитесь, что все слово всегда написано на уровне сборки. Например, рассмотрим случай, когда вы записываете значение 1 в регистр, за которым следует 2. Компилятор, особенно при оптимизации пространства, может решить перезаписать только младший байт, потому что остальные уже должны быть равны нулю (то есть для обычной оперативной памяти), или он может вместо этого удалить первую запись, потому что это значение, по-видимому, немедленно перезаписывается. Однако здесь не должно происходить ни того, ни другого. В C/C++ volatile Ключевое слово жизненно важно, чтобы предотвратить такие неподходящие оптимизации.
  • Во-вторых, и это актуально практически для любого разработчика, пишущего многопоточные программы, протокол когерентности кэша, хотя и предотвращает катастрофу, может иметь огромные потери производительности, если его "злоупотреблять".

Вот - несколько надуманный пример очень плохой структуры данных. Предположим, у вас есть 16 потоков, разбирающих текст из файла. Каждая нить имеет id от 0 до 15.

// shared state
char c[16];
FILE *file[16];

void threadFunc(int id)
{
    while ((c[id] = getc(file[id])) != EOF)
    {
        // ...
    }
}

Это безопасно, потому что каждый поток работает в разных местах памяти. Однако эти области памяти обычно находятся на одной и той же строке кэша или, самое большее, разделяются на две строки кэша. Протокол когерентности кэша затем используется для правильной синхронизации доступа к c[id], И в этом заключается проблема, потому что это заставляет каждый другой поток ждать, пока строка кэша не станет исключительно доступной, прежде чем делать что-либо с c[id], если только он не работает на ядре, которое "владеет" строкой кэша. Предполагая несколько, например, 16 ядер, когерентность кэша обычно будет постоянно переносить строку кэша от одного ядра к другому. По понятным причинам этот эффект известен как "пинг-понг кеша". Это создает ужасное узкое место производительности. Это является результатом очень плохого случая ложного совместного использования, то есть потоков, совместно использующих физическую строку кэша без фактического доступа к тем же логическим местам памяти.

В отличие от этого, особенно если принять дополнительный шаг к file Массив находится в собственной строке кэша, поэтому его использование будет абсолютно безвредным (на x86_64) с точки зрения производительности, поскольку указатели читаются только из большинства случаев. В этом случае несколько ядер могут "совместно использовать" строку кэша только для чтения. Только когда какое-либо ядро ​​пытается выполнить запись в строку кэша, оно должно сообщить другим ядрам, что оно собирается "захватить" строку кэша для монопольного доступа.

(Это значительно упрощается, поскольку существуют разные уровни кэшей ЦП, и несколько ядер могут использовать один и тот же кэш L2 или L3, но это должно дать вам общее представление о проблеме.)

Не уверен, что Страуструп имел в виду под "СЛОВОМ". Может быть, это минимальный объем памяти машины?

В любом случае, не все машины были созданы с разрешением 8 бит (BYTE). На самом деле я рекомендую эту замечательную статью Эрика С. Рэймонда, описывающую историю компьютеров: http://www.catb.org/esr/faqs/things-every-hacker-once-knew/

"... Раньше также было общеизвестно, что 36-битные архитектуры объясняют некоторые неприятные особенности языка C. В оригинальной машине Unix, PDP-7, использовались 18-битные слова, соответствующие полусловам в больших 36-битных системах. компьютеры. Они были более естественно представлены в виде шести восьмеричных (3-разрядных) цифр ".

Страуструп не говорит, что ни одна машина не может выполнять нагрузки и хранить меньше, чем их собственный размер слова, он говорит, что машина не может.

Хотя поначалу это кажется удивительным, в этом нет ничего эзотерического.
Для начала, мы будем игнорировать иерархию кэша, мы учтем это позже.
Предположим, что между процессором и памятью нет кэшей.

Большая проблема с памятью - плотность, пытающаяся поместить больше битов в самую маленькую область.
Чтобы добиться этого, с электрической точки зрения удобно представлять шину как можно шире (это способствует повторному использованию некоторых электрических сигналов, хотя я не рассматривал конкретные детали).
Таким образом, в архитектуре, где требуются большие объемы памяти (например, x86) или выгоден простой недорогой дизайн (например, когда задействованы RISC-машины), шина памяти больше, чем наименьшая адресуемая единица (обычно байт).

В зависимости от бюджета и унаследованного проекта, память может предоставлять более широкую шину в одиночку или вместе с некоторыми сигналами боковой полосы, чтобы выбрать в нее конкретный блок.
Что это означает практически?
Если вы посмотрите на таблицу данных DIMM DDR3, то увидите, что для чтения / записи данных имеется 64 контакта DQ0–DQ63.
Это шина данных, 64-битная, 8 байтов за раз.
Эта 8-байтовая вещь очень хорошо обоснована в архитектуре x86 до такой степени, что Intel ссылается на нее в разделе WC своего руководства по оптимизации, где говорится, что данные передаются из 64- байтового буфера заполнения (помните: мы игнорируем кэши для сейчас, но это похоже на то, как строка кэша записывается обратно) пакетами по 8 байт (надеюсь, непрерывно).

Значит ли это, что x86 может записывать только QWORDS (64-битную версию)?
Нет, та же самая таблица данных показывает, что каждый DIMM имеет сигналы DM0–DM7,DQ0–DQ7 и DQS0–DQS7 для маскировки, направления и стробирования каждого из 8 байтов в 64-битной шине данных.

Таким образом, x86 может читать и записывать байты естественным и атомарным образом
Однако теперь легко понять, что это не может быть так для любой архитектуры.
Например, видеопамять VGA была адресуемой DWORD (32-битной), и ее адаптация к адресуемому миру байтов 8086 приводила к грязным битовым плоскостям.

В целом архитектура специального назначения, как и DSP, не может иметь адресуемой памяти в байтах на аппаратном уровне.

Есть поворот: мы только что говорили о шине данных памяти, это самый низкий возможный уровень.
Некоторые процессоры могут иметь инструкции, которые создают адресную память байта поверх адресной памяти слова.
Что это значит?
Легко загрузить меньшую часть слова: просто отбросьте оставшиеся байты!
К сожалению, я не могу вспомнить название архитектуры (если она вообще существовала!), Где процессор имитировал загрузку невыровненного байта, читая выровненное слово, содержащее его, и поворачивая результат перед сохранением его в регистре.

С магазинами дело обстоит сложнее: если мы не можем просто написать часть слова, которую мы только что обновили, нам нужно написать также неизменную оставшуюся часть.
Процессор или программист должны прочитать старый контент, обновить его и записать обратно.
Это операция чтения-модификации-записи, и она является основной концепцией при обсуждении атомарности.

Рассматривать:

/* Assume unsigned char is 1 byte and a word is 4 bytes */
unsigned char foo[4] = {};

/* Thread 0                         Thread 1                 */
foo[0] = 1;                        foo[1] = 2;

Есть ли гонка данных?
Это безопасно на x86, потому что они могут записывать байты, но что, если архитектура не может?
Обе темы должны прочитать полностью foo массив, изменить его и записать обратно.
В псевдо-C это будет

/* Assume unsigned char is 1 byte and a word is 4 bytes */
unsigned char foo[4] = {};

/* Thread 0                        Thread 1                 */

/* What a CPU would do (IS)        What a CPU would do (IS) */
int tmp0 = *((int*)foo)            int tmp1 = *((int*)foo)

/* Assume little endian            Assume little endian     */
tmp0 = (tmp0 & ~0xff) | 1;         tmp1 = (tmp1 & ~0xff00) | 0x200;

/* Store it back                   Store it back            */
*((int*)foo) = tmp0;               *((int*)foo) = tmp1;

Теперь мы можем видеть, о чем говорил Страуструп: два магазина *((int*)foo) = tmpX препятствуйте друг другу, чтобы увидеть это, рассмотреть эту возможную последовательность выполнения:

int tmp0 = *((int*)foo)                   /* T0  */ 
tmp0 = (tmp0 & ~0xff) | 1;                /* T1  */        
int tmp1 = *((int*)foo)                   /* T1  */
tmp1 = (tmp1 & ~0xff00) | 0x200;          /* T1  */
*((int*)foo) = tmp1;                      /* T0  */
*((int*)foo) = tmp0;                      /* T0, Whooopsy  */

Если бы в C++ не было модели памяти, такие неудобства были бы деталями реализации, оставляя C++ бесполезным языком программирования в многопоточной среде.

Рассматривая, насколько распространенной является ситуация, изображенная в примере с игрушкой, Страуструп подчеркнул важность четко определенной модели памяти.
Формализация модели памяти - тяжелая работа, это утомительный, подверженный ошибкам и абстрактный процесс, поэтому я также вижу немного гордости в словах Страуструпа.

Я не разбираюсь в модели памяти C++, но обновление различных элементов массива - это нормально.
Это очень сильная гарантия.

Мы упустили кэши, но это ничего не меняет, по крайней мере, для случая x86.
X86 записывает в память через кэши, кэши выгружаются в строки по 64 байта.
Внутренне каждое ядро ​​может обновлять строку в любой позиции атомарно, если только загрузка / хранилище не пересекают границу линии (например, записывая в конце ее).
Этого можно избежать путем естественного выравнивания данных (можете ли вы доказать это?).

В среде с несколькими кодами / сокетами протокол когерентности кэша гарантирует, что только ЦП одновременно может свободно записывать в кэшированную строку памяти (ЦП, который имеет его в состоянии "Исключено" или "Изменено").
По сути, в семействе протоколов MESI используется концепция, аналогичная блокировке найденных СУБД.
Это дает эффект "назначения" разных областей памяти разным процессорам.
Так что это на самом деле не влияет на обсуждение выше.

Другие вопросы по тегам