64 位世界中的 32 位指针
如果您不需要访问 TB 级的 RAM,64 位指针是浪费的
引言
64 位非常棒 - 我们不再局限于仅 2GB (每个 Win32 进程) - 现在您可以寻址完整的 8TB 内存(每个 Win64 进程)... 如果您的应用程序确实需要 8TB 内存,那就太好了。
如果您不需要完整的地址空间,64 位可能会造成浪费:指针占用双倍的内存,这实际上可能导致应用程序更慢,尤其是在使用指针密集型数据结构(如二叉树)时(更大的结构占用更多的缓存空间)。
本文介绍了一种简单的方案,允许使用 32 位“指针”来访问 64 位机器上高达 64GB 的内存。
在 32 位中编码 64 位指针
sptr
的主要思想是数据对齐 - 现代处理器在访问“对齐”地址时感觉更舒服:在地址 0xC0001
处访问 char
意味着比在 0xC0000
处访问 int
需要更多工作。
malloc()
(为 new
做繁重工作) 仅返回对齐的地址 (参见 http://msdn.microsoft.com/en-us/library/ycsb6wwf.aspx) - malloc()
返回的指针在 Visual Studio 上将始终与 16 字节边界对齐 - 最低的 4 位将始终为 0。
因此,至少对于我们在堆上分配的指针,我们可以简单地“删除”最低的 4 位(通过右移)而不会丢失信息。
如果所有内存地址都是从“底部”分配的(最高位也为 0),我们可以按如下方式编码指针
#include <stddef.h> // for uintptr_t
typedef unsigned __int32 uint32_t;
uint32_t encode(void* p)
{
uintptr_t i = reinterpret_cast<uintptr_t>(p) >> 4;
return static_cast<uint32_t>(i);
}
(uintptr_t
是一种整数数据类型,保证与指针的大小相同。)
这会将范围 0x00 0000 0000
- 0x10 0000 0000
中的任何(16 字节对齐的)地址编码为 32 位整数。
但是,操作系统可以自由地将物理内存映射到其他虚拟内存段中 - 例如,将内核内存“在顶部”映射,因此最好自己保留这样的映射
#include <boost/thread/mutex.hpp>
#include <boost/thread/locks.hpp>
class sptr_base
{
protected:
static const uint32_t ALIGNMENT_BITS = 4;
static const uint32_t ALIGNMENT = (1<<ALIGNMENT_BITS);
static const uintptr_t ALIGNMENT_MASK = ALIGNMENT - 1;
protected:
static uintptr_t _seg_map[ALIGNMENT];
static uintptr_t _segs;
static boost::mutex _m;
inline static uintptr_t ptr2seg(uintptr_t p)
{
p &= ~0xFFFFFFFFULL; // Keep only high part
uintptr_t s = _segs;
uintptr_t i = 0;
for (; i < s; ++i)
if (_seg_map[i] == p)
return i;
// Not found - now we do it the "right" way (mutex and all)
boost::lock_guard<boost::mutex> lock(_m);
for (i = 0; i < s; ++i)
if (_seg_map[i] == p)
return i;
i = _segs++;
if (_segs > ALIGNMENT)
throw bad_segment("Segment out of range");
_seg_map[i] = p;
return i;
}
};
ptr2seg()
将指针的高位映射到几个段之一。该代码避免在读取映射时使用 mutex
,基于整数赋值的原子性。
那么,实际的指针编码/解码如下
#include <boost/pool/detail/singleton.hpp>
typedef boost::details::pool::singleton_default<segment_mapper> the_segment_mapper;
uint32_t encode(const_native_pointer_type ptr)
{
uintptr_t p = reinterpret_cast<uintptr_t>(ptr);
if ((p & ALIGNMENT_MASK) != 0)
throw bad_alignment("Pointer is not aligned");
return (uint32_t)(ptr2seg(p) + p);
}
inline native_pointer_type decode(uint32_t e)
{
uintptr_t el = e;
uintptr_t ptr = (_seg_map[el & ALIGNMENT_MASK] + el) & ~ALIGNMENT_MASK;
return reinterpret_cast<native_pointer_type>(ptr);
}
sptr
sptr
为“压缩”指针提供了类似指针的接口。 它允许以类似于实际指针的方式访问我们的“指针”
sptr<int> p = new int;
*p = 5;
delete p;
// Conversions from/to pointers and pointer arithmetics work
int* q = new int;
p = q;
p++;
q = p;
限制
sptr
有几个限制(这可能使其在许多地方不适用)。
首先,sptr
无法访问整个 64 位地址空间(duh!)。它仅适用于需要略多于 32 位地址空间的应用程序(服务器机器通常限制为 16GB-32GB 内存;台式机通常无法处理超过 8GB 的内存)。
其次,尽管在堆上分配的地址将正确对齐,但指针算术可能会使事情复杂化
sptr<char> buf = new char[100];
可以正常工作,因为 new
返回一个对齐的地址。
但是,类似以下的情况
for (sptr<char> p = buf; p != buf + 100; ++p)
*p = '0';
将无法工作,因为 p
现在将指向一个未对齐的地址(sptr
实现将在编译时断言)。
但是,在大多数情况下,可以使用标准指针
for (char* p = buf; p != buf + 100; ++p)
*p = '0';
第三,谨慎地将 sptr
与多重继承混合使用。
例如,给定
struct A { char i; };
struct B { int j; };
struct C : public A, public B { };
这可能会或可能无法工作(取决于结构对齐方式)
sptr<C> c = new C;
sptr<B> b = c;
这是因为 b
不指向与 c
相同的地址(这不是讨论多重继承的地方,但您可以尝试以下代码来说服自己)
C* c = new C;
B* b = c;
std::cout << ((uintptr_t)b - (uintptr_t)c) << std::endl;
通过标准指针访问对象没有问题
sptr<C> c = new C;
B* b = c;
性能
代码中提供了一个小型基准测试,它实现了一个简单的链表,该链表被重复遍历。
列表的节点是
struct node
{
int val;
node_ptr prev;
node_ptr next;
};
因此,使用标准指针的节点大小为 20 字节,而使用 sptr
的节点大小为 12 字节。
但是,这是以权衡为代价的 - 额外的位洗牌会影响性能 - 在链表基准测试中,标准指针版本将快 1.5 倍。
访问映射占了大部分性能损失。如果可以保证所有地址都是从区域 0x00 0000 0000 - 0x10 0000 0000
分配的,我们可以丢弃映射,从而显着提高性能。