« August 2017 | Main

September 20, 2017

Direct3D12 的接口设计 bug

昨天被 D3D12 的一个 bug 坑了一晚上,这个问题很值得一写。

最初是发现 LUID ID3D12Device::GetAdapterLuid() 这个函数有问题。我用 mingw64 gcc 编译后的程序,只要调用了一个 api ,d3d12device 设备对象的虚函数表就被破坏掉了。下一次对这个设备的任何 api 调用都会 crash 掉。

由于这个函数的实现在 d3d12.dll 中,是没有源码的,所以只能用 gdb 调试了一下。发现了一个问题:这个 api 的返回值是 LUID ,它是一个结构体。C/C++ 函数返回结构体是没有统一的调用规范的,按道理 COM 的实现应该避免设计这种 API 。

按 COM 的规定,所有 API 必须返回 HRESULT ,只有少量例外可以返回 ULONG 整数。其实之前的 D3D 版本在设计类似 API 的时候都符合了这个约定,例如 D3D9 就有一个类似的 API :

HRESULT GetAdapterLUID( [in] UINT Adapter, [in] LUID *pLUID )

我猜想,微软的新同学在重新设计 D3D12 的时候已经忘记了 COM 规范,也不清楚这个世界上还有 VC 以外的 C++ 编译器的存在。实现 D3D12.DLL 的时候,按 VC 的调用规范,当 API 返回一个结构体的时候,是把调用方在栈上预留出的返回结构的地址作为一个参数传入的。

所以,这个 API 其实本质上是这样的:

void ID3D12Device::GetAdapterLuid(LUID *)

而 gcc 呢,它会根据结构体的实际大小来优化调用。如果结构体小于等于 64bit ,就通过寄存器返回结果,而不会传入堆栈地址。在 gcc 上,这个 API 就只有一个参数,就是 this 指针了。按 64 位系统的函数调用规范,第一个和第二个参数分别通过 rcx 和 rdx 传递。用 gcc 生成的调用代码,调用方认为没有第二个参数,rdx 是无意义的。这里恰巧 rdx 也保存了 this 。而被调用方,也就是 d3d12.dll 的实现,认为第二个参数是有意义的,是返回结构的地址,结果就把返回数据写入了 *this 中,这里恰好是这个对象的虚表之所在,程序就这么挂了。

那么是否可以指定 gcc 不要对小结构体返回优化呢?

gcc 提供了参数 -fpcc-struct-return 。不过,即使设置了这个参数,gcc 的 ABI 依旧和 vc 的不一致。因为对于 gcc 来说,返回值的地址是第一个参数,而 vc 似乎放在了第二个,它把第一个参数留给了 this 。

在微软更新 sdk 前,怎么绕过这个问题呢?我写了一个辅助函数:

static inline LUID D3D12DeviceGetAdapterLuid(ID3D12Device *device) {
    typedef void (STDMETHODCALLTYPE  ID3D12Device::*GetAdapterLuid_f)(LUID *);
    LUID ret;
    (device->*(GetAdapterLuid_f)(&ID3D12Device::GetAdapterLuid))(&ret);
    return ret;
}

同样存在问题的 API 还有 ID3D12DescriptorHeap 等,我还没有一一核查。


更糟糕的问题存在于 ID3D12Resource::GetDesc 这个 API ,它也返回了一个结构体 D3D12_RESOURCE_DESC ,这个 API 居然在 d3dsdk 的 d3dx12.h 中的 inline 函数 UpdateSubresources 里被调用了。

也就是说,如果你不修改 d3dsdk 的 .h 文件,几乎无法解决这个 bug :(

我一开始想到一个 trick ,实现一个 proxy 类,把这个 GetDesc 函数重定义一下:

    static inline D3D12_RESOURCE_DESC ID3D12ResourceGetDesc(ID3D12Resource *res)
    {
        typedef void (STDMETHODCALLTYPE  ID3D12Resource::*GetDesc_f)(D3D12_RESOURCE_DESC *);
        D3D12_RESOURCE_DESC ret;
        (res->*(GetDesc_f)(&ID3D12Resource::GetDesc))(&ret);
        return ret;
    }

    struct D3D12ResourceProxy : public ID3D12Resource {
    D3D12ResourceProxy(ID3D12Resource *p) : m_ptr(p) {}
    virtual HRESULT STDMETHODCALLTYPE Map(UINT Subresource, const D3D12_RANGE *pReadRange, void **ppData) {
        return m_ptr->Map(Subresource, pReadRange, ppData);
    }
    virtual void STDMETHODCALLTYPE Unmap(UINT Subresource, const D3D12_RANGE *pWrittenRange) {
        m_ptr->Unmap(Subresource, pWrittenRange);
    }
    virtual D3D12_RESOURCE_DESC STDMETHODCALLTYPE GetDesc(void) {
        // return struct 重新定义
        return ID3D12ResourceGetDesc(m_ptr);
    }
    virtual D3D12_GPU_VIRTUAL_ADDRESS STDMETHODCALLTYPE GetGPUVirtualAddress( void) {
        return m_ptr->GetGPUVirtualAddress();
    }
    virtual HRESULT STDMETHODCALLTYPE WriteToSubresource(UINT DstSubresource, const D3D12_BOX *pDstBox, const void *pSrcData, UINT SrcRowPitch, UINT SrcDepthPitch) {
        return m_ptr->WriteToSubresource(DstSubresource, pDstBox, pSrcData, SrcRowPitch, SrcDepthPitch);
    }
    virtual HRESULT STDMETHODCALLTYPE ReadFromSubresource(void *pDstData,UINT DstRowPitch,UINT DstDepthPitch, UINT SrcSubresource, const D3D12_BOX *pSrcBox) {
        return m_ptr->ReadFromSubresource(pDstData,DstRowPitch,DstDepthPitch,SrcSubresource,pSrcBox);
    }
    virtual HRESULT STDMETHODCALLTYPE GetHeapProperties(D3D12_HEAP_PROPERTIES *pHeapProperties,D3D12_HEAP_FLAGS *pHeapFlags) {
        return m_ptr->GetHeapProperties(pHeapProperties,pHeapFlags);
    }
    virtual HRESULT STDMETHODCALLTYPE GetDevice(REFIID riid, void **ppvDevice) {
        return m_ptr->GetDevice(riid, ppvDevice);
    }
    virtual HRESULT STDMETHODCALLTYPE GetPrivateData(REFGUID guid, UINT *pDataSize, void *pData) {
        return m_ptr->GetPrivateData(guid,pDataSize,pData);
    }
    virtual HRESULT STDMETHODCALLTYPE SetPrivateData(REFGUID guid, UINT DataSize, const void *pData) {
        return m_ptr->SetPrivateData(guid, DataSize, pData);
    }
    virtual HRESULT STDMETHODCALLTYPE SetPrivateDataInterface(REFGUID guid, const IUnknown *pData) {
        return m_ptr->SetPrivateDataInterface(guid, pData);
    }
    virtual HRESULT STDMETHODCALLTYPE SetName(LPCWSTR Name) {
        return m_ptr->SetName(Name);
    }
    virtual ULONG AddRef() {
        return m_ptr->AddRef();
    }
    virtual HRESULT QueryInterface(REFIID riid, void **ppvObject) {
        return m_ptr->QueryInterface(riid, ppvObject);
    }
    virtual ULONG Release() {
        return m_ptr->Release();
    }

    private:
    ID3D12Resource *m_ptr;
    };

然后,在调用 UpdateSubresources 的地方修改一下,先把 ID3D12Resource * 转换为 &D3D12ResourceProxy(p) 再传进去。后来发现这样不行,因为在 UpdateSubresources 内部又调用了 ID3D12GraphicsCommandList::CopyTextureRegion ,它会接收 ID3D12Resource * 。而 ID3D12GraphicsCommandList::CopyTextureRegion 已经实现在 d3d12.dll 中,这样又会回到调用协议不一致的问题。

把 sdk 实现放在公开的 .h 中真是个糟糕的设计。

September 07, 2017

日程表服务

skynet 的用户中,问的比较多的一个问题是,为什么我改了系统时间对 skynet 却没有生效?继续追问发现,有这个需求的人大多是想实现一个日程表,到某个特定时间触发特定的任务,修改系统时间是为了测试。

不得不说,通过修改系统时间来测试是个直接、却很糟糕的主意。skynet 的定时器也不依赖系统时间驱动,修改系统时间自然也不会生效。

日程服务是个普遍的需求。在国内网游里,你要不做什么节日任务、每周副本,基本不可能上线。这篇 blog 就谈谈这类需求应该在 skynet 中如何实现。

最好的方法是实现一个单独的服务,用来控制日程。这样、如果你有测试的需求,也可以通过预留的接口让这个服务模拟时间快进,快速触发定时任务了。

通常,日程任务不需要特别高的时间精度,精确到分钟就够了。我们一般不会精确到秒来设定任务开启的时间。日程设置也多半和具体日期、星期几有关。常见的需求类似这样:每周六晚上 8 点开启;每个月的第二个周四中午 12 点;6 月 1 日儿童节活动;等等。

如果要设计一个这样的日程表服务,其实只需要一个订阅接口,类似 skynet timer 那样单次触发的就够了。触发接口就是传入一个时间点,可以描述上面例子中描述的日程。我们在具体活动实现的服务中用 skynet.call 这个日程安排服务,当 skynet.call 返回时,就是日程时间触发点。例如,我们需要每周 6 晚上 8 点固定开启一个活动,可以用一个 while 循环:

while true do
  skynet.call(schedule_service, "lua", { wday = 7 , hour = 20 } )
  -- 执行活动
end

这个日程表服务每次接到订阅请求,就按照参数计算出下一个符合要求的触发时间点,然后用 skynet.sleep 等到触发时间即可。

如果出于调试需要改动当前时间,直接通知这个服务,调整其内部时间和真实时间的时间差,wakeup 所有正在等待的请求,重新计算等待时间就好了。


这样一个日程表服务并不复杂,我花了一点时间随手实现了一个,供大家参考。它应该有很大的改进空间,明白了其设计之后,应该不难完善它:

local skynet = require "skynet"
local service = require "skynet.service"

local schedule = {}
local service_addr

-- { month=, day=, wday=, hour= , min= }
function schedule.submit(ti)
    return skynet.call(service_addr, "lua", ti)
end

function schedule.changetime(ti)
    local tmp = {}
    for k,v in pairs(ti) do
        tmp[k] = v
    end
    tmp.changetime = true
    return skynet.call(service_addr, "lua", tmp)
end

skynet.init(function()
    local schedule_service = function()
-- schedule service

local skynet = require "skynet"

local task = { session = 0, difftime = 0 }

local function next_time(now, ti)
    local nt = {
        year = now.year ,
        month = now.month ,
        day = now.day,
        hour = ti.hour or 0,
        min = ti.min or 0,
        sec = ti.sec,
    }
    if ti.wday then
        -- set week
        assert(ti.day == nil and ti.month == nil)
        nt.day = nt.day + ti.wday - now.wday
        local t = os.time(nt)
        if t < now.time then
            nt.day = nt.day + 7
        end
    else
        -- set day, no week day
        if ti.day then
            nt.day = ti.day
        end
        if ti.month then
            nt.month = ti.month
        end
        local t = os.time(nt)
        if t < now.time then
            if ti.month then
                nt.year = nt.year + 1   -- next year
            else
                nt.month = nt.month + 1 -- next month
            end
        end
    end

    return os.time(nt)
end

local function changetime(ti)
    local ct = math.floor(skynet.time())
    local current = os.date("*t", ct)
    current.time = ct
    if not ti.hour then
        ti.hour = current.hour
    end
    if not ti.min then
        ti.min = current.min
    end
    ti.sec = current.sec
    local nt = next_time(current, ti)
    skynet.error(string.format("Change time to %s", os.date(nil, nt)))
    task.difftime = os.difftime(nt,ct)
    for k,v in pairs(task) do
        if type(v) == "table" then
            skynet.wakeup(v.co)
        end
    end
    skynet.ret()
end

local function submit(_, addr, ti)
    if ti.changetime then
        return changetime(ti)
    end
    local session = task.session + 1
    task.session = session
    repeat
        local ct = math.floor(skynet.time()) + task.difftime
        local current = os.date("*t", ct)
        current.time = ct
        local nt = next_time(current, ti)
        task[session] = { time = nt, co = coroutine.running(), address = addr }
        local diff = os.difftime(nt , ct)
        print("sleep", diff)
    until skynet.sleep(diff * 100) ~= "BREAK"
    task[session] = nil
    skynet.ret()
end

skynet.start(function()
    skynet.dispatch("lua", submit)
    skynet.info_func(function()
        local info = {}
        for k, v in pairs(task) do
            if type(v) == "table" then
                table.insert( info, {
                    time = os.date(nil, v.time),
                    address = skynet.address(v.address),
                })
            end
            return info
        end
    end)
end)

-- end of schedule service
    end

    service_addr = service.new("schedule", schedule_service)
end)

return schedule

用 schedule.submit 可以申请一个时间点,到时后会返回。使用的时候只需要用 skynet.fork 开一个独立线程,循环调用 schedule.submit 即可。

schedule.changetime 支持修改当前时间,供调试用。