>>Optimize โปรแกรมง่ายๆ ด้วยการ cache ตัวแปร

Optimize โปรแกรมง่ายๆ ด้วยการ cache ตัวแปร

By |2018-10-22T07:17:52+00:00October 22nd, 2018|Programming|0 Comments

เริ่มต้นจากตัวอย่างง่ายๆ

หากพูดถึงการ cache ตัวแปรที่พอจะเห็นภาพง่ายๆ ก็อย่างเช่น

// Cast AOE skill
foreach (Enemy enemy in m_enemies)
{
  enemy.ApplyDamage(m_player.BaseDamage + m_player.BonusDamage);
}

foreach (Entity building in m_buildings)
{
  building.ApplyDamage(m_player.BaseDamage + m_player.BonusDamage);
}

จะเห็นว่าเรานั้นคำนวนค่าโจมตีใหม่ทุกครั้งในลูปแต่ละรอบ และนอกจากนี้มันอาจจะก่อให้เกิด bug หากเราใส่สูตรการคำนวนผิดในส่วนที่เพิ่มเข้าไปเช่น

// Cast AOE skill
foreach (Entity enemy in m_enemies)
{
  enemy.ApplyDamage(m_player.BaseDamage + m_player.BonusDamage);
}

foreach (Entity building in m_buildings)
{
  building.ApplyDamage(m_player.BaseDamage + m_player.BonusDamage);
}

foreach (Entity ally in m_allies)
{
  // Bug! it should be m_player.BaseDamage + m_player.BonusDamage
  ally.ApplyDamage(m_player.BaseDamage + m_player.BaseDamage);
}

จากตัวอย่างนี้นอกจากจะทำให้เกิด bug ง่ายแล้วยังเปลืองการคำนวน ดังนั้นแล้วจึงควร cache มันไว้แบบนี้

// Cast AOE skill
int totalDamage = m_player.BaseDamage + m_player.BonusDamage;

foreach (Entity enemy in m_enemies)
{
  enemy.ApplyDamage(totalDamage);
}

foreach (Entity building in m_buildings)
{
  building.ApplyDamage(totalDamage);
}

foreach (Entity ally in m_allies)
{
  ally.ApplyDamage(totalDamage);
}

การ cache reference

ตัวอย่างข้างบนเป็นการ cache ตัวแปรแบบ value type คราวนี้ลองมาดูแบบ reference type บ้าง

การใช้งาน engine หรือ framework ที่มีการใช้ service locator design pattern จะมีฟังชั่นประเภท  GetService(Type type) ซึ่งการทำงานของมันก็คือค้นหา service/component/system (แล้วแต่ว่าเป็นอะไร)  ใช่แล้ว  Unity Engine เองก็มีการใช้งาน design pattern นี้ นั่นก็คือ GameObject.GetComponent(Type type) แน่นอนว่าทุกวันนี้เอกสารของ Unity เองแนะนำให้ cache component ที่จะใช้ไว้ ไม่ควรค้นหาใหม่ทุกรอบการทำงาน  และในเวอชั่นปัจจุบัน Unity เอา shortcut properties พวก GameObject.rigidBody, GameObject.camera ออก เพราะสุดท้ายแล้วมันก็คือ GameObject.GetComponent<RigidBody>(), GameObject.GetComponent<Camera>().  ยกเว้นไว้ตัวนึงคือ GameObject.transform ซึ่งต่างจากอันอื่นตรงที่มันไม่ได้เรียก GameObject.GetComponent<Transform>()

สรุปก็คือแทนที่จะเขียนแบบนี้

public class Example : MonoBehaviour
{
    // Use this for initialization
    void Start()
    {

    }

    // Update is called once per frame
    void Update()
    {
        var myRenderer = GetComponent<Renderer>();
        DoSomething(myRenderer);
    }

    void DoSomething(Renderer renderer)
    {
    }
}

ก็ให้เขียนเป็นแบบนี้

public class Example : MonoBehaviour
{
    private Renderer myRenderer;

    // Use this for initialization
    void Start()
    {
        myRenderer = GetComponent<Renderer>();
    }

    // Update is called once per frame
    void Update()
    {
        DoSomething(myRenderer);
    }

    void DoSomething(Renderer renderer)
    {
    }
}

จริงๆแล้วก็รวมพวก method ประเภทค้นหาทั้งหลายด้วยน่ะ เช่น GameObject.Find. ทำการ cache ไว้เถอะอย่าไปเรียกมันทุกเฟรมเลย

แม้แต่ c++ ก็ต้อง cache pointer เพื่อความเร็ว

แม้จะเป็นภาษาที่เร็ว การละเลย best practice ก็ไม่ช่วยอะไรคุณได้  ขอยกตัวอย่าง Urho3D เป็น c++ game engine ที่มีการใช้ service locator design pattern

void VehicleDemo::HandleUpdate()
{
  if (!GetSubsystem<UI>()->GetFocusElement())
  {
    vehicle_->controls_.Set(CTRL_FORWARD, GetSubsystem<Input>()->GetKeyDown(KEY_W));
    vehicle_->controls_.Set(CTRL_BACK, GetSubsystem<Input>()->GetKeyDown(KEY_S));
    vehicle_->controls_.Set(CTRL_LEFT, GetSubsystem<Input>()->GetKeyDown(KEY_A));
    vehicle_->controls_.Set(CTRL_RIGHT, GetSubsystem<Input>()->GetKeyDown(KEY_D));
    vehicle_->controls_.yaw_ += (float)GetSubsystem<Input>()->GetMouseMoveX() * YAW_SENSITIVITY;
    vehicle_->controls_.pitch_ += (float)GetSubsystem<Input>()->GetMouseMoveY() * YAW_SENSITIVITY;
    
    // Limit pitch
    vehicle_->controls_.pitch_ = Clamp(vehicle_->controls_.pitch_, 0.0f, 80.0f);
  }
  else
  {
    vehicle_->controls_.Set(CTRL_FORWARD | CTRL_BACK | CTRL_LEFT | CTRL_RIGHT, false);
  }
}

จะเห็นได้ว่าจะเรียกใช้ Input ทีก็เรียก GetSubsystem<Input>() ทุกรอบ เปลือง cpu cycle โดยใช่เหตุ ฉะนั้นเราก็ cache มันก่อน

void VehicleDemo::HandleUpdate()
{
  auto* input = GetSubsystem<Input>();
  auto* ui = GetSubsystem<UI>();

  if (!ui->GetFocusElement())
  {
    vehicle_->controls_.Set(CTRL_FORWARD, input->GetKeyDown(KEY_W));
    vehicle_->controls_.Set(CTRL_BACK, input->GetKeyDown(KEY_S));
    vehicle_->controls_.Set(CTRL_LEFT, input->GetKeyDown(KEY_A));
    vehicle_->controls_.Set(CTRL_RIGHT, input->GetKeyDown(KEY_D));
    vehicle_->controls_.yaw_ += (float)input->GetMouseMoveX() * YAW_SENSITIVITY;
    vehicle_->controls_.pitch_ += (float)input->GetMouseMoveY() * YAW_SENSITIVITY;

    // Limit pitch
    vehicle_->controls_.pitch_ = Clamp(vehicle_->controls_.pitch_, 0.0f, 80.0f);
  }
  else
  {
    vehicle_->controls_.Set(CTRL_FORWARD | CTRL_BACK | CTRL_LEFT | CTRL_RIGHT, false);
  }
}

ถ้าจะให้ดีกว่านี้ก็ cache มันใน data member ซะเลย

ทำการ cache ordinals ตอนอ่าน database

เวลาที่เราเขียนโปรแกรมเพื่ออ่านข้อมูลจาก database เนี่ย  เวลาที่เราเข้าถึงข้อมูลเราควรใช้ ordinal แทนที่จะเป็นค่า string ตรงๆเลย ยกตัวอย่างการเปิด MySql connection เพื่ออ่านข้อมูลใน c#

using (var connection = new MySqlConnection(ConnectionString))
{
    connection.Open();
 
    using (var command = new MySqlCommand(commandText, connection))
    {

        using (MySqlDataReader reader = command.ExecuteReader())
        {
            while (reader.Read())
            {
                int monsterId = reader.GetInt32("monster_id");
                float health = reader.GetFloat("health");
            }
 
            // Call Close when done reading.
            reader.Close();
        }
    }
}

แทนที่เราจะใช้ string เพื่อเข้าถึง เราก็ cache ordinals ไว้ก่อน แล้วก็นำไปใช้ใน loop while ได้เลย

using (var connection = new MySqlConnection(ConnectionString))
{
    connection.Open();
 
    using (var command = new MySqlCommand(commandText, connection))
    {

        using (MySqlDataReader reader = command.ExecuteReader())
        {
           int monsterIdOrdinal = reader.GetOrdinal("monster_id");
           int healthOrdinal = reader.GetOrdinal("health");

           while (reader.Read())
           {
              int itemId = reader.GetInt32(monsterIdOrdinal);
              float health = reader.GetFloat(healthOrdinal);
           }

            // Call Close when done reading.
            reader.Close();
        }
    }
}

เนื่องจากด้วยความที่ผมเองไม่ใช่โปรแกรมเมอร์สาย database มาก่อน จะทึกทักเอาเองไม่ได้ว่ามันเร็วกว่า จึงได้ทำการ benchmark ดู  แน่นอนครับว่าเร็วกว่าทุกกรณี ตั้งแต่ 50% – 400%  ผมเองก็ไม่ทราบเหมือนกันว่าทำไม MySql ถึง overload parameter ของ IDataReader.Get*() ให้มันเข้าถึงค่าได้จากทั้ง ordinal และ string มันทำให้คนมักง่ายเรียกใช้แต่ที่เป็น string ใน loop  ซึ่งผิดกับ Sql ของ Microsoft ที่ไม่มี overload parameter นี้อยู่

แถมให้อีกหน่อย

ตัวอย่างหลังๆมาเป็นเรื่องของการ cache ตัวแปรไว้เพื่อไม่ให้โปรแกรมเรา look up ใหม่บ่อยๆ  มาลองดูอีกตัวอย่างสุดท้ายที่ผมนึกออก

โดยทั่วไปของ game engine ที่มีระบบ scene graph  ในตัว node เองอย่างน้อยๆ จะมี rotation, position, scale และก่อนที่มันจะถูกส่งไปวาด  เราจะต้องสร้าง matrix ขึ้นมาจาก rotation, position, scale ก่อน ซึ่งกระบวนการสร้างแต่ละครั้งก็ไม่ใช่ถูกๆ  จากที่ว่ามา Node จะมีหน้าตาประมาณนี้

public class Node
{
    public Matrix4x4 Transform
    {
        get { return Matrix4x4.TRS(m_position, m_rotation, m_scale); }
    }

    private Vector3 m_position;
    private Quaternion m_rotation;
    private Vector3 m_scale;

    public void SetPosition(Vector3 position)
    {
        m_position = position;
    }

    public void SetRotation(Quaternion rotation)
    {
        m_rotation = rotation;
    }

    public void SetScale(Vector3 scale)
    {
        m_scale = scale;
    }
}

พอเราเรียก Node.Transform มันก็จะสร้าง Matrix4x4 ใหม่ทุกรอบ แน่นอนว่ามันแพงมากลองนึกว่าเรามีซัก 1000 nodes มีขยับจริงๆอยู่แค่ 50 nodes ก็คงจะเปลือง  แต่เราสามารถ cache Trasform ไว้ได้ด้วยการใช้ flag ตัวหนึ่งเพื่อคอยบอกว่า Node ควร update Matrix4x4 ใหม่ได้แล้ว แบบนี้

public class Node
{
    private Matrix4x4 m_cachedTransform;
    private bool m_cachedTransformOutOfDate;

    public Matrix4x4 Transform
    {
        get
        {
            if (m_cachedTransformOutOfDate)
            {
                m_cachedTransform = Matrix4x4.TRS(m_position, m_rotation, m_scale);
                m_cachedTransformOutOfDate = false;
            }

            return m_cachedTransform;
        }
    }

    private Vector3 m_position;
    private Quaternion m_rotation;
    private Vector3 m_scale;

    public void SetPosition(Vector3 position)
    {
        m_position = position;
        m_cachedTransformOutOfDate = true;
    }

    public void SetRotation(Quaternion rotation)
    {
        m_rotation = rotation;
        m_cachedTransformOutOfDate = true;
    }

    public void SetScale(Vector3 scale)
    {
        m_scale = scale;
        m_cachedTransformOutOfDate = true;
    }
}

ทีนี้ก็จะกลายเป็นว่า Transform จะถูกสร้างใหม่ก็ต่อเมื่อมีการเรียก SetPosition(), SetRotation(), SetScale() เท่านั้น!!!

โดยคร่าวๆแล้ว game engine ก็จะใช้วิธีการ cache transformation ของ node ประมาณนี้  แต่ผมก็ไปแอบเห็น open source 3d engine เก่าๆ ตัวนึงแหละที่ไม่ได้ cache transformation

หวังว่าจะได้ไอเดียกันไปไม่มากก็น้อยกับโพสนี้  เพราะเรื่องนี้ถูกมองข้ามบ่อยมาก