Как работает оперативная память? Почему это постоянный случайный доступ?

или другими словами, почему доступ к произвольному элементу в массиве занимает постоянное время (вместо O(n) или в другой раз)?

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

когда я говорю array[4] = 12 в a программа, Я действительно просто храню битовое представление адреса памяти в регистре. Этот физический регистр в аппаратном обеспечении включит соответствующие электрические сигналы в соответствии с битовым представлением, которое я ему подал. Затем эти электрические сигналы каким-то волшебным образом ( Надеюсь, кто-то может объяснить магию ) получат доступ к правильному адресу памяти в физической/основной памяти.

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

(Примечание редактора: из более поздних комментариев OP он понимает, что вычисления адресов занимают постоянное время, и просто задается вопросом о том, что происходит после этого.)

4 ответов


потому что программное обеспечение любит O (1) "рабочую" память, и, следовательно, аппаратное обеспечение предназначено для такого поведения

основной момент заключается в том, что адресное пространство программы рассматривается как абстрактно имеющее производительность доступа O(1), т. е. независимо от местоположения памяти, которое вы хотите прочитать, оно должно занять некоторое постоянное время (которое в любом случае не связано с расстоянием между ним и последним доступом к памяти). Таким образом, будучи массивами не более чем непрерывными кусками адресного пространства, они должны наследовать это собственность (доступ к элементу массива-это просто вопрос добавления индекса на начальный адрес массива, а затем разыменования указателя получаются).

это свойство исходит из того, что, в общем случае, адресное пространство программы имеет некоторое соответствие с физическим ОЗУ ПК, которое, как имя (оперативной памяти) частично подразумевает, что должно иметь само по себе свойство, которое, независимо от местоположения в ОЗУ, к которому вы хотите получить доступ, вы получите это в постоянное время (в отличие, например, от ленточного накопителя, где время поиска зависит от фактической длины ленты, которую вам нужно переместить, чтобы добраться туда).

теперь, для "обычной" ОЗУ это свойство (по крайней мере, AFAIK) верно-когда процессор / материнская плата / контроллер памяти запрашивает чип ОЗУ, чтобы получить некоторые данные, он делает это в постоянное время; детали на самом деле не актуальны для разработки программного обеспечения, а внутренние чипы памяти менялись много раз в прошлом и будут меняться снова в будущем. Если вас интересует обзор деталей текущих Баранов, вы можете посмотреть здесь о драмов.

общая концепция заключается в том, что чипы RAM не содержат ленту, которая должна быть перемещена, или дисковый рычаг, который должен быть расположен; когда вы спрашиваете их байт в каком-то месте, работа (в основном изменение настроек некоторых аппаратных муксов, которые подключают выход к ячейкам, где хранится статус байта) одинакова для любого места, где вы можете быть запрашивая; таким образом, вы получаете O(1) performance

за этим есть некоторые накладные расходы (логический адрес должен быть сопоставлен с физическим адресом MMU, различные части материнской платы должны разговаривать друг с другом, чтобы сказать ОЗУ, чтобы получить данные и вернуть их процессору ...), но оборудование предназначено для этого в более или менее постоянное время.

Так:

массивы отображают адресное пространство, которое отображается через ОЗУ, которое имеет O (1) произвольный доступ; будучи все карты (более или менее) O(1), массивы сохраняют O(1) производительность произвольного доступа ОЗУ.


точка тут для разработчиков программного обеспечения важно то, что, хотя мы видим плоское адресное пространство, и оно обычно отображается через ОЗУ, на современных машинах неверно, что доступ к любому элементу имеет ту же стоимость. Фактически, доступ к элементам, которые находятся в одной зоне, может быть путь дешевле, чем скакать вокруг адресного пространства, из-за того, что процессор имеет несколько встроенных кэшей (=меньшие, но более быстрые памяти на чипе), которые хранят недавно использованные данные и память, которая находится в том же районе; таким образом, если у вас есть хорошая локальность данных, непрерывные операции в памяти не будут продолжать попадать в ОЗУ (которые имеют гораздо большую задержку, чем кэши), и в конце концов ваш код будет работать намного быстрее.

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


вычисление для получения от начала массива до любого заданного элемента занимает всего две операции: умножение (times sizeof (element)) и сложение. Обе эти операции-постоянное время. Часто с сегодняшними процессорами это можно сделать практически в кратчайшие сроки, так как процессор оптимизирован для такого доступа.


когда я говорю array[4] = 12 в программе, я действительно просто сохраняю бит представление адреса памяти в регистр. Этот физический регистрация в аппаратном обеспечении включит соответствующее электрическое сигналы в соответствии с битовым представлением, которое я ему передал. Эти электрические сигналы будут тогда как-то волшебно ( надеюсь, кто-то может объяснить магия ) доступ к адресу памяти в физической/основной памяти.

Я не совсем уверен, что вы спрашиваете, но я не вижу никаких ответов, связанных с тем, что действительно происходит в волшебном оборудования. Надеюсь, я понял достаточно, чтобы пройти через это длинное объяснение (которое все еще очень высокий уровень).

array[4] = 12;

таким образом, из комментариев похоже, что понятно, что вы должны получить базовый адрес массива, а затем умножить на размер элемента массива (или сдвинуть, если это возможно), чтобы получить адрес (с точки зрения ваших программ) памяти местоположение. Справа от летучей мыши у нас проблема. Эти предметы уже есть в регистрах или мы должны их получить? Базовый адрес массива может быть или не быть в регистре в зависимости от кода, окружающего эту строку кода, в частности кода, предшествующего ей. Этот адрес может быть в стеке или в другом месте в зависимости от того, где вы его объявили и как. И это может иметь или не иметь значения, сколько времени это займет. Оптимизирующий компилятор может (часто) зайти так далеко, что предварительно вычислить адрес массива[4] и место, которое где-то, чтобы он мог войти в регистр, и умножение никогда не происходит во время выполнения, поэтому абсолютно неверно, что вычисление массива[4] для случайного доступа является фиксированным количеством времени по сравнению с другими случайными доступами. В зависимости от процессора некоторые непосредственные шаблоны являются одной инструкцией, другие берут больше, что также влияет на то, считывается ли этот адрес .текст или стек или и т. д., и т. д. ... Чтобы не курица и яйцо, что проблема до смерти, предположим, что мы вычислить адрес массива[4].

Это операция записи с точки зрения программистов. Начиная с простого процессора, без кэша, без буфера записи, без mmu и т. д. В конце концов, простой процессор поставит адрес на краю ядра процессора, со стробом записи и данными, каждая шина процессоров отличается от других семейств процессоров, но это примерно тот же адрес и данные могут выходить в том же цикле или в отдельных циклах. Тип команды (чтение, запись) может происходить одновременно или по-разному. но команда выходит. Край процессорного ядра подключен к контроллеру памяти, который декодирует этот адрес. Результатом является назначение, является ли это периферийным, если да, то какой и на какой шине, является эта память, если да, то на какой шине памяти и так далее. Предположим, ОЗУ, предположим, что этот простой процессор имеет sram не dram. Sram дороже и быстрее в сравнении яблок с яблоками. Sram имеет адрес и стробы записи/чтения и другие органы управления. В конце концов у вас будет тип транзакции, чтение/запись, адрес и данные. SRAM однако его геометрия будет маршрутизировать и хранить отдельные биты в их отдельных парах / группах транзисторов.

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

теперь добавьте некоторую сложность. ECC или любое другое имя, которое вы хотите назвать (EDAC, является еще одним). То, как работает память ECC, - это записи фиксированного размера, даже если ваша реализация составляет четыре 8-битные части памяти, дающие вам 32 бита данных на запись, вы должны иметь фиксированное с тем, что охватывает ECC, и вы должны написать биты данных плюс биты ecc все одновременно (должны вычислить ecc по всей ширине). Итак, если это было 8 бит, напишите например, в 32-битную защищенную память ECC, тогда этот цикл записи требует цикла чтения. Прочитайте 32 бита (проверьте ecc на этом чтении) измените новые 8 бит в этом 32-битном шаблоне, вычислите новый шаблон ecc, напишите 32 бита плюс биты ecc. Естественно, что прочитанная часть цикла записи может закончиться ошибкой ecc, что просто делает жизнь еще более веселой. Одиночные битовые ошибки могут быть исправлены обычно (что хорошего в ECC/EDAC, если он не может), многоразрядные ошибки нет. Как спроектировано оборудование чтобы справиться с этими ошибками, влияет на то, что происходит дальше, ошибка чтения может просто просочиться обратно в процессор, нарушающий транзакцию записи, или она может вернуться как прерывание и т. д. Но вот еще одно место, где один случайный доступ не совпадает с другим, в зависимости от доступной памяти, и размер доступа для чтения-изменения-записи определенно занимает больше времени, чем простая запись.

Dram также может попасть в эту категорию фиксированной ширины, даже без ECC. На самом деле все памяти в какой-то момент попадает в эту категорию. Массив памяти оптимизирован на кремнии для определенной высоты и ширины в единицах битов. Вы не можете нарушить эту память, она может быть прочитана и записана только в единицах этой ширины на этом уровне. Библиотеки кремния будут включать в себя множество геометрий ОЗУ, и дизайнеры будут выбирать эти геометрии для своих деталей, а детали будут иметь фиксированные ограничения, и часто вы можете использовать несколько деталей, чтобы получить некоторую целочисленную кратную ширину этого размера, а иногда конструкция позволит вам писать только на одну из этих частей, если только некоторые биты меняются, или некоторые конструкции заставят все части загораться. Обратите внимание, как следующее семейство модулей ddr, которые вы подключаете к домашнему компьютеру или ноутбуку, первая волна много частей по обе стороны платы. Затем, когда эта технология становится старше и более скучной, она может меняться на меньшее количество частей по обе стороны доски, в конечном итоге становясь меньшим количеством частей на одной стороне доски до того, как эта технология устарели и мы уже в следующем.

эта фиксированная категория ширины также несет с собой штрафы за выравнивание. К сожалению, большинство людей учатся на машинах x86, которые не ограничивают вас выровненными доступом, как и многие другие платформы. Существует определенное наказание за производительность на x86 или других для несогласованных доступа, если это разрешено. Обычно, когда люди идут в mips или обычно руку на каком-то устройстве с батарейным питанием, это когда они впервые узнают как программисты о выровненных доступах. И к сожалению, они являются болезненными, а не благословением (из-за простоты программирования и аппаратных преимуществ, которые приходят от него). Короче говоря, если ваша память имеет ширину 32 бита и может быть доступна только для чтения или записи, 32 бита одновременно, это означает, что она ограничена только выровненными доступом. Шина памяти на 32-битной памяти обычно не имеет более низких адресных битов a[1:0], потому что они не используются. эти нижние биты с точки зрения программистов-нули. если хотя наша запись была 32 бит против одного из этих 32-битных воспоминаний, а адрес был 0x1002. Затем кто-то вдоль линии должен прочитать память по адресу 0x1000 и взять два наших байта и изменить это 32-битное значение, а затем записать его обратно. Затем возьмите 32 бита по адресу 0x1004 и измените два байта и напишите его обратно. четыре цикла шины для одной записи. Если бы мы писали 32 бита для адреса 0x1008, хотя это была бы простая 32-битная запись, без чтения.

sram vs dram. dram мучительно медленно, но очень дешево. от Половины до четверти числа транзисторов на бит. (4 для sram, например 1 для dram). Срам помнит бит, пока включен свет. Dram должен быть обновлен, как перезаряжаемая батарея. Даже если мощность остается на одном бит будет помнить только в течение очень короткого периода времени. Поэтому некоторые аппаратные средства (контроллер ddr и т. д.) должны регулярно выполнять циклы шины, сообщая, что ОЗУ запоминает определенный кусок памяти. Этот цикл украсть время у процессора, который хочет получить доступ к памяти. dram очень медленный, он может сказать 2133Mhz (2.133 ghz) на коробке. Но это действительно больше похоже на 133mhz ram, справа 0.133 Ghz. Первый обман-ddr. Обычно вещи в цифровом мире происходят один раз за такт. Часы идут в состоянии утверждаемое затем переходит в состояние мгновенно (единицы и нули) один цикл-один часы. РДР означает, что он может что-то делать как на высоком полупериоде, так и на низком полупериоде. так что память 2133Ghz действительно использует часы 1066mhz. Затем происходит конвейер, подобный параллелизму, вы можете вставлять команды, пачками, с такой высокой скоростью, но в конечном итоге эта ОЗУ должна фактически получить доступ. В целом драма не determinstic и очень медленно. С другой стороны, Sram не требует обновления, он помнит, пока питание включено. Может быть в несколько раз быстрее (133mhz * N), и так далее. Она может быть детерминированной.

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

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

несколько уровней кэшей. l1, l2, l3...L1 обычно самый быстрый либо по своей технологии, либо по близости, и обычно самый маленький, и он поднимается оттуда скорость и размер, и некоторые из них имеют отношение к стоимости памяти. Мы делаем запись, но когда вы делаете кэш включен чтение понять, что если l1 имеет промах он переходит к l2 который, если он имеет промах идет к l3 который, если он имеет промах идет в основную память, то l3, l2 и l1 все будет хранить копию. Таким образом, промах на всех 3, Конечно, самый болезненный и медленнее, чем если бы у вас не было кэша вообще, но последовательные чтения дадут вам кэшированные элементы, которые теперь находятся в l1 и супер быстро, для кэша, чтобы быть полезное последовательное чтение по строке кэша должно занимать меньше времени, чем чтение этого объема памяти непосредственно из медленного dram. Система не должна иметь 3 слоя кэшей, она может варьироваться. Точно так же некоторые системы могут отделять выборки инструкций от чтения данных и могут иметь отдельные кэши, которые не выселяют друг друга, а некоторые кэши не являются отдельными, и выборки инструкций могут выселять данные из чтения данных.

кэши помогают с проблемами выравнивания. Но, конечно, есть еще более суровое наказание за несогласованный доступ по линиям кэша. Кэши, как правило, работают с использованием кусков памяти, называемых линиями кэша. Это часто какое-то целое число, кратное размеру памяти с другой стороны. 32-разрядная память, например, строка кэша может быть 128 бит или 256 бит, например. Поэтому, если и когда строка кэша находится в кэше, то чтение-изменение-запись из-за несогласованной записи против более быстрой памяти, еще более болезненной, чем выровненная, но не столь болезненной. Если бы это было unaligned read и адрес был таким, что часть этих данных находится на одной стороне границы строки кэша, а другая-на другой, тогда должны быть прочитаны две строки кэша. 16-битное чтение, например, может стоить вам много байтов, прочитанных против самой медленной памяти, очевидно, в несколько раз медленнее, чем если бы у вас не было кэшей вообще. В зависимости от того, как кэши и система памяти в целом спроектированы, если вы делаете запись через границу строки кэша, это может быть так же болезненно или, возможно, не так сильно пусть фракция записывается в кэш, а другая фракция выходит на дальнюю сторону как запись меньшего размера.

следующий уровень сложности-mmu. Позволяя процессору и программисту иллюзию плоских пространств памяти и/или контроль над тем, что кэшируется или нет, и/или защиту памяти, и/или иллюзию, что все программы работают в одном адресном пространстве (так что ваша цепочка инструментов всегда может компилировать / ссылка для адреса 0x8000, например). Mmu принимает часть виртуального адрес на стороне ядра процессора. выглядит, что в таблице или серии таблиц эти поиски часто находятся в системном адресном пространстве, поэтому каждый из этих поисков может быть одним или несколькими из всего, что указано выше, поскольку каждый из них является циклом памяти в системной памяти. Эти поиски могут привести к ошибкам ecc, даже если вы пытаетесь сделать запись. В конце концов после одного или двух или трех или более чтений mmu определил, что адрес находится на другой стороне mmu, и свойства (cacheable или нет, и т. д.), И это передается на следующую вещь (l1 и т. д.), И все вышеизложенное применяется. Некоторые mmus имеют немного кэша в них некоторого количества предыдущих транзакций, помните, потому что программы последовательны, трюки, используемые для повышения иллюзии производительности памяти, основаны на последовательных обращениях, а не случайных обращениях. Таким образом, некоторое количество поисков может быть сохранено в mmu, поэтому ему не нужно сразу выходить в основную память...

Так в современном компьютере с mmus, кэши, dram, последовательные чтения, в частности, но и записи, вероятно, будут быстрее, чем случайный доступ. Разница может быть разительной. Первая транзакция в последовательном чтении или записи в этот момент является случайным доступом, поскольку она не была замечена никогда или в течение некоторого времени. Как только последовательность продолжается, хотя оптимизации падают в порядке, а следующие несколько/некоторые заметно быстрее. Размер и выравнивание транзакции также играет важную роль в производительности. Пока есть так многие недетерминированные вещи происходят, как программист с этими знаниями вы модифицируете свои программы, чтобы работать намного быстрее, или, если не повезло или нарочно может изменить свои программы, чтобы работать намного медленнее. Последовательный будет, в общем, быстрее на одной из этих систем. случайный доступ будет очень недетерминированным. array[4]=12; за ним следует array[37]=12; эти две операции высокого уровня могут занять резко различное количество времени, как при вычислении адреса записи, так и при собственно пишет сам. Но, например, discarded_variable=array[3]; array[3]=11; array[4]=12; может довольно часто выполняться значительно быстрее, чем array[3]=11; array[4]=12;


массивы в C и C++ имеют случайный доступ, потому что они хранятся в оперативной памяти в конечном, предсказуемом порядке. В результате для определения местоположения данной записи требуется простая линейная операция(a[i] = a + sizeof (a[0]) * i). Этот расчет имеет постоянное время. С точки зрения ЦП, операция" поиск "или" перемотка "не требуется, она просто сообщает памяти"загрузить значение по адресу X".

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

все же-общий принцип заключается в том, что время для извлечения заданного набора из 4 или 8 байтов из ОЗУ одинаково независимо от адреса. Е. Г. если с чистого листа, вы RAM доступ[0] и памяти[4294967292] ЦП получите ответ в течение того же количества циклов.

#include <iostream>
#include <cstring>
#include <chrono>

// 8Kb of space.
char smallSpace[8 * 1024];

// 64Mb of space (larger than cache)
char bigSpace[64 * 1024 * 1024];

void populateSpaces()
{
    memset(smallSpace, 0, sizeof(smallSpace));
    memset(bigSpace, 0, sizeof(bigSpace));
    std::cout << "Populated spaces" << std::endl;
}

unsigned int doWork(char* ptr, size_t size)
{
    unsigned int total = 0;
    const char* end = ptr + size;
    while (ptr < end) {
        total += *(ptr++);
    }
    return total;
}

using namespace std;
using namespace chrono;

void doTiming(const char* label, char* ptr, size_t size)
{
    cout << label << ": ";
    const high_resolution_clock::time_point start = high_resolution_clock::now();
    auto result = doWork(ptr, size);
    const high_resolution_clock::time_point stop = high_resolution_clock::now();
    auto delta = duration_cast<nanoseconds>(stop - start).count();
    cout << "took " << delta << "ns (result is " << result << ")" << endl;
}

int main()
{
    cout << "Timer resultion is " << 
        duration_cast<nanoseconds>(high_resolution_clock::duration(1)).count()
        << "ns" << endl;

    populateSpaces();

    doTiming("first small", smallSpace, sizeof(smallSpace));
    doTiming("second small", smallSpace, sizeof(smallSpace));
    doTiming("third small", smallSpace, sizeof(smallSpace));
    doTiming("bigSpace", bigSpace, sizeof(bigSpace));
    doTiming("bigSpace redo", bigSpace, sizeof(bigSpace));
    doTiming("smallSpace again", smallSpace, sizeof(smallSpace));
    doTiming("smallSpace once more", smallSpace, sizeof(smallSpace));
    doTiming("smallSpace last", smallSpace, sizeof(smallSpace));
}

демо: http://ideone.com/9zOW5q

вывод (из ideone, который может быть не идеальным)

Success  time: 0.33 memory: 68864 signal:0
Timer resultion is 1ns
Populated spaces
doWork/small: took 8384ns (result is 8192)
doWork/small: took 7702ns (result is 8192)
doWork/small: took 7686ns (result is 8192)
doWork/big: took 64921206ns (result is 67108864)
doWork/big: took 65120677ns (result is 67108864)
doWork/small: took 8237ns (result is 8192)
doWork/small: took 7678ns (result is 8192)
doWork/small: took 7677ns (result is 8192)
Populated spaces
strideWork/small: took 10112ns (result is 16384)
strideWork/small: took 9570ns (result is 16384)
strideWork/small: took 9559ns (result is 16384)
strideWork/big: took 65512138ns (result is 134217728)
strideWork/big: took 65005505ns (result is 134217728)

то, что мы видим здесь влияние кэша на производительность доступа к памяти. При первом попадании в smallSpace требуется ~8100ns для доступа ко всем 8kb небольшого пространства. Но когда мы вызываем его снова сразу после, дважды, это занимает ~600ns меньше на ~7400ns.

теперь мы уходим и делаем bigspace, который больше, чем текущий кэш процессора, поэтому мы знаем, что мы сдули L1 и L2 кэше.

возвращаясь к small, который, мы уверены, не кэшируется сейчас, мы снова видим ~8100ns в первый раз и ~7400 для вторых двух.

мы выдуваем кэш, и теперь мы вводим другое поведение. Мы используем версию strided loop. Это усиливает эффект " пропуска кэша "и значительно увеличивает время, хотя" небольшое пространство " вписывается в кэш L2, поэтому мы все еще видим сокращение между проходом 1 и следующими 2 проходами.